From: Olyutorskii Date: Thu, 26 Aug 2010 07:38:56 +0000 (+0900) Subject: from subversion repository X-Git-Tag: fromMercurial~118 X-Git-Url: http://git.osdn.net/view?p=jindolf%2FJindolf.git;a=commitdiff_plain;h=eca55768e8a7b2a6736c444d9f8d2f14eed0c272 from subversion repository --- diff --git a/src/main/java/jp/sourceforge/jindolf/AbstractTextRow.java b/src/main/java/jp/sourceforge/jindolf/AbstractTextRow.java new file mode 100644 index 0000000..fac1353 --- /dev/null +++ b/src/main/java/jp/sourceforge/jindolf/AbstractTextRow.java @@ -0,0 +1,149 @@ +/* + * 矩形領域テキスト描画抽象クラス + * + * Copyright(c) 2008 olyutorskii + * $Id: AbstractTextRow.java 959 2009-12-14 14:11:01Z olyutorskii $ + */ + +package jp.sourceforge.jindolf; + +import java.awt.Rectangle; +import java.awt.font.GlyphVector; +import java.text.CharacterIterator; + +/** + * TextRowの実装を助けるクラス。 + */ +public abstract class AbstractTextRow implements TextRow{ + + /** 描画領域矩形。 */ + protected final Rectangle bounds = new Rectangle(); + /** フォント指定。 */ + protected FontInfo fontInfo; + + private boolean visible = true; + + /** + * コンストラクタ。 + */ + protected AbstractTextRow(){ + this(FontInfo.DEFAULT_FONTINFO); + return; + } + + /** + * コンストラクタ。 + * @param fontInfo フォント設定 + */ + protected AbstractTextRow(FontInfo fontInfo){ + this.fontInfo = fontInfo; + return; + } + + /** + * {@inheritDoc} + * @param newWidth {@inheritDoc} + * @return {@inheritDoc} + */ + public Rectangle setWidth(int newWidth){ + this.bounds.width = newWidth; + recalcBounds(); + return this.bounds; + } + + /** + * {@inheritDoc} + * @return {@inheritDoc} + */ + public Rectangle getBounds(){ + return this.bounds; + } + + /** + * {@inheritDoc} + * @param xPos {@inheritDoc} + * @param yPos {@inheritDoc} + */ + public void setPos(int xPos, int yPos){ + this.bounds.x = xPos; + this.bounds.y = yPos; + return; + } + + /** + * {@inheritDoc} + * @return {@inheritDoc} + */ + public int getWidth(){ + return this.bounds.width; + } + + /** + * {@inheritDoc} + * @return {@inheritDoc} + */ + public int getHeight(){ + return this.bounds.height; + } + + /** + * {@inheritDoc} + * @param fontInfo {@inheritDoc} + */ + public void setFontInfo(FontInfo fontInfo){ + this.fontInfo = fontInfo; + return; + } + + /** + * {@inheritDoc} + * @return {@inheritDoc} + */ + public boolean isVisible(){ + return this.visible; + } + + /** + * {@inheritDoc} + * @param visible {@inheritDoc} + */ + public void setVisible(boolean visible){ + this.visible = visible; + return; + } + + /** + * 文字列からグリフ集合を生成する。 + * @param iterator 文字列 + * @return グリフ集合 + */ + public GlyphVector createGlyphVector(CharacterIterator iterator){ + return this.fontInfo.createGlyphVector(iterator); + } + + /** + * 文字列からグリフ集合を生成する。 + * @param seq 文字列 + * @return グリフ集合 + */ + public GlyphVector createGlyphVector(CharSequence seq){ + CharacterIterator iterator; + iterator = new SequenceCharacterIterator(seq); + return this.fontInfo.createGlyphVector(iterator); + } + + /** + * 文字列からグリフ集合を生成する。 + * @param seq 文字列 + * @param from 開始位置 + * @param to 終了位置 + * @return グリフ集合 + */ + public GlyphVector createGlyphVector(CharSequence seq, + int from, int to ){ + CharacterIterator iterator; + iterator = new SequenceCharacterIterator(seq, from, to); + return this.fontInfo.createGlyphVector(iterator); + } + +} diff --git a/src/main/java/jp/sourceforge/jindolf/AccountCookie.java b/src/main/java/jp/sourceforge/jindolf/AccountCookie.java new file mode 100644 index 0000000..878599f --- /dev/null +++ b/src/main/java/jp/sourceforge/jindolf/AccountCookie.java @@ -0,0 +1,187 @@ +/* + * account cookie + * + * Copyright(c) 2008 olyutorskii + * $Id: AccountCookie.java 888 2009-11-04 06:23:35Z olyutorskii $ + */ + +package jp.sourceforge.jindolf; + +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URISyntaxException; +import java.text.DateFormatSymbols; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.Locale; +import java.util.TimeZone; + +/** + * 人狼BBSアカウント管理用のCookie。 + * JRE1.6 HttpCookie の代用品。 + */ +class AccountCookie{ // TODO JRE 1.6対応とともにHttpCookieへ移行予定 + + // 人狼BBSのCookie期限表記例: 「Thu, 26 Jun 2008 06:44:34 GMT」 + private static final String DATE_FORM = "EEE, dd MMM yyyy HH:mm:ss z"; + private static final SimpleDateFormat FORMAT; + + static{ + Calendar calendar = new GregorianCalendar(); + TimeZone zoneGMT = TimeZone.getTimeZone("GMT"); + DateFormatSymbols customSyms = new DateFormatSymbols(); + String[] sweekdays = customSyms.getShortWeekdays(); + sweekdays[Calendar.SUNDAY] = "Sun"; + sweekdays[Calendar.MONDAY] = "Mon"; + sweekdays[Calendar.TUESDAY] = "Tue"; + sweekdays[Calendar.WEDNESDAY] = "Wed"; + sweekdays[Calendar.THURSDAY] = "Thu"; + sweekdays[Calendar.FRIDAY] = "Fri"; + sweekdays[Calendar.SATURDAY] = "Sat"; + customSyms.setShortWeekdays(sweekdays); + String[] months = customSyms.getShortMonths(); + months[Calendar.JANUARY] = "Jan"; + months[Calendar.FEBRUARY] = "Feb"; + months[Calendar.MARCH] = "Mar"; + months[Calendar.APRIL] = "Apr"; + months[Calendar.MAY] = "May"; + months[Calendar.JUNE] = "Jun"; + months[Calendar.JULY] = "Jul"; + months[Calendar.AUGUST] = "Aug"; + months[Calendar.SEPTEMBER] = "Sep"; + months[Calendar.OCTOBER] = "Oct"; + months[Calendar.NOVEMBER] = "Nov"; + months[Calendar.DECEMBER] = "Dec"; + customSyms.setShortMonths(months); + + FORMAT = new SimpleDateFormat(DATE_FORM, Locale.JAPAN); + FORMAT.setCalendar(calendar); + FORMAT.setTimeZone(zoneGMT); + FORMAT.setDateFormatSymbols(customSyms); + FORMAT.setLenient(true); + } + + private final String loginData; + private final URI pathURI; + private final Date expireDate; + + /** + * 認証クッキーの生成。 + * @param loginData 認証データ + * @param path Cookieパス + * @param expireDate expire日付 + * @throws java.lang.NullPointerException 引数がnull + * @throws java.lang.IllegalArgumentException パスが変 + */ + public AccountCookie(String loginData, String path, Date expireDate) + throws NullPointerException, IllegalArgumentException{ + super(); + + if(loginData == null || path == null || expireDate == null){ + throw new NullPointerException(); + } + + this.loginData = loginData; + try{ + this.pathURI = new URI(path); + }catch(URISyntaxException e){ + throw new IllegalArgumentException(path, e); + } + this.expireDate = expireDate; + + return; + } + + /** + * Cookie期限が切れてないか判定する。 + * @return 期限が切れていたらtrue + */ + public boolean hasExpired(){ + long nowMs = System.currentTimeMillis(); + long expireMs = this.expireDate.getTime(); + if(expireMs < nowMs) return true; + return false; + } + + /** + * Cookieパスを返す。 + * @return Cookieパス + */ + public URI getPathURI(){ + return this.pathURI; + } + + /** + * 認証データを返す。 + * @return 認証データ + */ + public String getLoginData(){ + return this.loginData; + } + + /** + * 認証Cookieを抽出する。 + * @param cookieSource HTTPヘッダ 「Cookie=」の値 + * @return 認証Cookie + */ + public static AccountCookie createCookie(String cookieSource){ + String[] cookieParts = cookieSource.split("; "); + if(cookieParts.length <= 0) return null; + + String login = null; + String path = null; + String expires = null; + for(String part : cookieParts){ + String[] nmval = part.split("=", 2); + if(nmval == null) continue; + if(nmval.length != 2) continue; + String name = nmval[0]; + String value = nmval[1]; + + if(name.equals("login")){ + login = value; + }else if(name.equals("path")){ + path = value; + }else if(name.equals("expires")){ + expires = value; + } + } + if(login == null || path == null || expires == null) return null; + + Date date; + try{ + date = FORMAT.parse(expires); + }catch(ParseException e){ + return null; + } + + AccountCookie cookie = new AccountCookie(login, path, date); + + return cookie; + } + + /** + * 認証Cookieを抽出する。 + * @param connection HTTP接続 + * @return 認証Cookie + */ + public static AccountCookie createCookie(HttpURLConnection connection){ + String cookieHeader = connection.getHeaderField("Set-Cookie"); + if(cookieHeader == null) return null; + AccountCookie cookie = createCookie(cookieHeader); + return cookie; + } + + /** + * 認証Cookieの文字列表記。 + * @return String文字列 + */ + @Override + public String toString(){ + return this.loginData; + } + +} diff --git a/src/main/java/jp/sourceforge/jindolf/AccountPanel.java b/src/main/java/jp/sourceforge/jindolf/AccountPanel.java new file mode 100644 index 0000000..ba3e670 --- /dev/null +++ b/src/main/java/jp/sourceforge/jindolf/AccountPanel.java @@ -0,0 +1,474 @@ +/* + * Account panel + * + * Copyright(c) 2008 olyutorskii + * $Id: AccountPanel.java 956 2009-12-13 15:14:07Z olyutorskii $ + */ + +package jp.sourceforge.jindolf; + +import java.awt.BorderLayout; +import java.awt.Container; +import java.awt.Frame; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.awt.event.ItemEvent; +import java.awt.event.ItemListener; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JComboBox; +import javax.swing.JComponent; +import javax.swing.JDialog; +import javax.swing.JLabel; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JPasswordField; +import javax.swing.JSeparator; +import javax.swing.JTextArea; +import javax.swing.JTextField; +import javax.swing.border.Border; +import jp.sourceforge.jindolf.corelib.LandState; + +/** + * ログインパネル。 + */ +@SuppressWarnings("serial") +public class AccountPanel + extends JDialog + implements ActionListener, ItemListener{ + + private static final String FRAMETITLE = + "アカウント管理 - " + Jindolf.TITLE; + + private final Map landUserIDMap = + new HashMap(); + private final Map landPasswordMap = + new HashMap(); + + private final JComboBox landBox = new JComboBox(); + private final JTextField idField = new JTextField(15); + private final JPasswordField pwField = new JPasswordField(15); + private final JButton loginButton = new JButton("ログイン"); + private final JButton logoutButton = new JButton("ログアウト"); + private final JButton closeButton = new JButton("閉じる"); + private final JTextArea status = new JTextArea(); + + /** + * アカウントパネルを生成。 + * @param owner フレームオーナー + * @param landsModel 国モデル + * @throws java.lang.NullPointerException 引数がnull + */ + public AccountPanel(Frame owner, LandsModel landsModel) + throws NullPointerException{ + super(owner, FRAMETITLE, true); + + if(landsModel == null) throw new NullPointerException(); + for(Land land : landsModel.getLandList()){ + String userID = ""; + char[] password = {}; + this.landUserIDMap.put(land, userID); + this.landPasswordMap.put(land, password); + this.landBox.addItem(land); + } + + GUIUtils.modifyWindowAttributes(this, true, false, true); + + this.landBox.setToolTipText("アカウント管理する国を選ぶ"); + this.idField.setToolTipText("IDを入力してください"); + this.pwField.setToolTipText("パスワードを入力してください"); + + Monodizer.monodize(this.idField); + Monodizer.monodize(this.pwField); + + this.idField.setMargin(new Insets(1, 4, 1, 4)); + this.pwField.setMargin(new Insets(1, 4, 1, 4)); + + this.idField.setComponentPopupMenu(new TextPopup()); + + this.landBox.setEditable(false); + this.landBox.addItemListener(this); + + this.status.setEditable(false); + this.status.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5)); + this.status.setRows(2); + this.status.setLineWrap(true); + + this.loginButton.addActionListener(this); + this.logoutButton.addActionListener(this); + this.closeButton.addActionListener(this); + + getRootPane().setDefaultButton(this.loginButton); + + Container content = getContentPane(); + GridBagLayout layout = new GridBagLayout(); + GridBagConstraints constraints = new GridBagConstraints(); + content.setLayout(layout); + + constraints.gridwidth = GridBagConstraints.REMAINDER; + constraints.weightx = 1.0; + constraints.insets = new Insets(5, 5, 5, 5); + + JComponent accountPanel = createCredential(); + JComponent buttonPanel = createButtonPanel(); + + constraints.weighty = 0.0; + constraints.fill = GridBagConstraints.HORIZONTAL; + content.add(accountPanel, constraints); + + Border border = BorderFactory.createTitledBorder("ログインステータス"); + JPanel panel = new JPanel(); + panel.setLayout(new BorderLayout()); + panel.add(this.status, BorderLayout.CENTER); + panel.setBorder(border); + + constraints.weighty = 1.0; + constraints.fill = GridBagConstraints.BOTH; + content.add(panel, constraints); + + constraints.weighty = 0.0; + constraints.fill = GridBagConstraints.HORIZONTAL; + content.add(new JSeparator(), constraints); + + content.add(buttonPanel, constraints); + + preSelectActiveLand(); + + updateGUI(); + + return; + } + + /** + * 認証パネルを生成する。 + * @return 認証パネル + */ + private JComponent createCredential(){ + JPanel credential = new JPanel(); + + GridBagLayout layout = new GridBagLayout(); + GridBagConstraints constraints = new GridBagConstraints(); + + credential.setLayout(layout); + + constraints.insets = new Insets(5, 5, 5, 5); + constraints.fill = GridBagConstraints.NONE; + + constraints.anchor = GridBagConstraints.EAST; + credential.add(new JLabel("国名 :"), constraints); + constraints.anchor = GridBagConstraints.WEST; + credential.add(this.landBox, constraints); + + constraints.gridy = 1; + constraints.anchor = GridBagConstraints.EAST; + constraints.weightx = 0.0; + constraints.fill = GridBagConstraints.NONE; + credential.add(new JLabel("ID :"), constraints); + constraints.anchor = GridBagConstraints.WEST; + constraints.weightx = 1.0; + constraints.fill = GridBagConstraints.HORIZONTAL; + credential.add(this.idField, constraints); + + constraints.gridy = 2; + constraints.anchor = GridBagConstraints.EAST; + constraints.weightx = 0.0; + constraints.fill = GridBagConstraints.NONE; + credential.add(new JLabel("パスワード :"), constraints); + constraints.anchor = GridBagConstraints.WEST; + constraints.weightx = 1.0; + constraints.fill = GridBagConstraints.HORIZONTAL; + credential.add(this.pwField, constraints); + + return credential; + } + + /** + * ボタンパネルの作成。 + * @return ボタンパネル + */ + private JComponent createButtonPanel(){ + JPanel buttonPanel = new JPanel(); + + GridBagLayout layout = new GridBagLayout(); + GridBagConstraints constraints = new GridBagConstraints(); + + buttonPanel.setLayout(layout); + + constraints.fill = GridBagConstraints.NONE; + constraints.anchor = GridBagConstraints.WEST; + constraints.weightx = 0.0; + constraints.weighty = 0.0; + + buttonPanel.add(this.loginButton, constraints); + + constraints.insets = new Insets(0, 5, 0, 0); + buttonPanel.add(this.logoutButton, constraints); + + constraints.anchor = GridBagConstraints.EAST; + constraints.weightx = 1.0; + constraints.insets = new Insets(0, 15, 0, 0); + buttonPanel.add(this.closeButton, constraints); + + return buttonPanel; + } + + /** + * 現在コンボボックスで選択中の国を返す。 + * @return 現在選択中のLand + */ + private Land getSelectedLand(){ + Land land = (Land)( this.landBox.getSelectedItem() ); + return land; + } + + /** + * ACTIVEな最初の国がコンボボックスで既に選択されている状態にする。 + */ + private void preSelectActiveLand(){ + for(int index = 0; index < this.landBox.getItemCount(); index++){ + Object item = this.landBox.getItemAt(index); + Land land = (Land) item; + LandState state = land.getLandDef().getLandState(); + if(state == LandState.ACTIVE){ + this.landBox.setSelectedItem(land); + return; + } + } + return; + } + + /** + * 指定された国のユーザIDを返す。 + * @param land 国 + * @return ユーザID + */ + private String getUserID(Land land){ + return this.landUserIDMap.get(land); + } + + /** + * 指定された国のパスワードを返す。 + * @param land 国 + * @return パスワード + */ + private char[] getPassword(Land land){ + return this.landPasswordMap.get(land); + } + + /** + * ネットワークエラーを通知するモーダルダイアログを表示する。 + * OKボタンを押すまでこのメソッドは戻ってこない。 + * @param e ネットワークエラー + */ + protected void showNetworkError(IOException e){ + Jindolf.logger().warn( + "アカウント処理中にネットワークのトラブルが発生しました", e); + + Land land = getSelectedLand(); + ServerAccess server = land.getServerAccess(); + String message = + land.getLandDef().getLandName() + +"を運営するサーバとの間の通信で" + +"何らかのトラブルが発生しました。\n" + +"相手サーバのURLは [ " + server.getBaseURL() + " ] だよ。\n" + +"Webブラウザでも遊べないか確認してみてね!\n"; + + JOptionPane pane = new JOptionPane(message, + JOptionPane.WARNING_MESSAGE, + JOptionPane.DEFAULT_OPTION ); + + JDialog dialog = pane.createDialog(this, + "通信異常発生 - " + Jindolf.TITLE); + + dialog.pack(); + dialog.setVisible(true); + dialog.dispose(); + + return; + } + + /** + * アカウントエラーを通知するモーダルダイアログを表示する。 + * OKボタンを押すまでこのメソッドは戻ってこない。 + */ + protected void showIllegalAccountDialog(){ + Land land = getSelectedLand(); + String message = + land.getLandDef().getLandName() + +"へのログインに失敗しました。\n" + +"ユーザ名とパスワードは本当に正しいかな?\n" + +"あなたは本当に [ " + getUserID(land) + " ] さんかな?\n" + +"WebブラウザによるID登録手続きは本当に完了してるかな?\n" + +"Webブラウザでもログインできないか試してみて!\n" + +"…ユーザ名やパスワードにある種の特殊文字を使っている人は" + +"問題があるかも。"; + + JOptionPane pane = new JOptionPane(message, + JOptionPane.WARNING_MESSAGE, + JOptionPane.DEFAULT_OPTION ); + + JDialog dialog = + pane.createDialog(this, "ログイン認証失敗 - " + Jindolf.TITLE); + + dialog.pack(); + dialog.setVisible(true); + dialog.dispose(); + + return; + } + + /** + * 入力されたアカウント情報を基に現在選択中の国へログインする。 + * @return ログインに成功すればtrueを返す。 + */ + protected boolean login(){ + Land land = getSelectedLand(); + ServerAccess server = land.getServerAccess(); + + String id = this.idField.getText(); + char[] password = this.pwField.getPassword(); + this.landUserIDMap.put(land, id); + this.landPasswordMap.put(land, password); + + boolean result = false; + try{ + result = server.login(id, password); + }catch(IOException e){ + showNetworkError(e); + return false; + } + + if( ! result ){ + showIllegalAccountDialog(); + } + + return result; + } + + /** + * 現在選択中の国からログアウトする。 + */ + protected void logout(){ + try{ + logoutInternal(); + }catch(IOException e){ + showNetworkError(e); + } + return; + } + + /** + * 現在選択中の国からログアウトする。 + * @throws java.io.IOException ネットワークエラー + */ + protected void logoutInternal() throws IOException{ + Land land = getSelectedLand(); + ServerAccess server = land.getServerAccess(); + server.logout(); + return; + } + + /** + * 現在選択中の国のログイン状態に合わせてGUIを更新する。 + */ + private void updateGUI(){ + Land land = getSelectedLand(); + LandState state = land.getLandDef().getLandState(); + ServerAccess server = land.getServerAccess(); + boolean hasLoggedIn = server.hasLoggedIn(); + + if(state != LandState.ACTIVE){ + this.status.setText( + "この国は既に募集を停止しました。\n" + +"ログインは無意味です" ); + this.idField.setEnabled(false); + this.pwField.setEnabled(false); + this.loginButton.setEnabled(false); + this.logoutButton.setEnabled(false); + }else if(hasLoggedIn){ + this.status.setText("ユーザ [ " + getUserID(land) + " ] として\n" + +"現在ログイン中です"); + this.idField.setEnabled(false); + this.pwField.setEnabled(false); + this.loginButton.setEnabled(false); + this.logoutButton.setEnabled(true); + }else{ + this.status.setText("現在ログインしていません"); + this.idField.setEnabled(true); + this.pwField.setEnabled(true); + this.loginButton.setEnabled(true); + this.logoutButton.setEnabled(false); + } + + return; + } + + /** + * {@inheritDoc} + * ボタン操作のリスナ。 + * @param event イベント {@inheritDoc} + */ + // TODO Return キー押下によるログインもサポートしたい + public void actionPerformed(ActionEvent event){ + Object source = event.getSource(); + + if(source == this.closeButton){ + setVisible(false); + dispose(); + return; + } + + if(source == this.loginButton){ + login(); + }else if(source == this.logoutButton){ + logout(); + } + + updateGUI(); + + return; + } + + /** + * {@inheritDoc} + * コンボボックス操作のリスナ。 + * @param event イベント {@inheritDoc} + */ + public void itemStateChanged(ItemEvent event){ + Object source = event.getSource(); + if(source != this.landBox) return; + + Land land = (Land) event.getItem(); + String id; + char[] password; + + switch(event.getStateChange()){ + case ItemEvent.SELECTED: + id = getUserID(land); + password = getPassword(land); + this.idField.setText(id); + this.pwField.setText(new String(password)); + updateGUI(); + break; + case ItemEvent.DESELECTED: + id = this.idField.getText(); + password = this.pwField.getPassword(); + this.landUserIDMap.put(land, id); + this.landPasswordMap.put(land, password); + break; + default: + assert false; + return; + } + + return; + } + + // TODO IDかパスワードが空の場合はログインボタンを無効にしたい +} diff --git a/src/main/java/jp/sourceforge/jindolf/ActionManager.java b/src/main/java/jp/sourceforge/jindolf/ActionManager.java new file mode 100644 index 0000000..cf518c1 --- /dev/null +++ b/src/main/java/jp/sourceforge/jindolf/ActionManager.java @@ -0,0 +1,528 @@ +/* + * action manager + * + * Copyright(c) 2008 olyutorskii + * $Id: ActionManager.java 935 2009-12-02 12:10:29Z olyutorskii $ + */ + +package jp.sourceforge.jindolf; + +import java.awt.Insets; +import java.awt.event.ActionListener; +import java.awt.event.KeyEvent; +import java.net.URL; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import javax.swing.AbstractButton; +import javax.swing.ButtonGroup; +import javax.swing.ButtonModel; +import javax.swing.Icon; +import javax.swing.ImageIcon; +import javax.swing.JButton; +import javax.swing.JMenu; +import javax.swing.JMenuBar; +import javax.swing.JMenuItem; +import javax.swing.JRadioButtonMenuItem; +import javax.swing.JToolBar; +import javax.swing.KeyStroke; +import javax.swing.LookAndFeel; +import javax.swing.UIManager; + +/** + * メニュー、ボタン、その他各種Actionを伴うイベントを生成する + * コンポーネントの一括管理。 + */ +public class ActionManager{ + + /** アクション{@value}。 */ + public static final String CMD_ACCOUNT = "ACCOUNT"; + /** アクション{@value}。 */ + public static final String CMD_EXIT = "EXIT"; + /** アクション{@value}。 */ + public static final String CMD_COPY = "COPY"; + /** アクション{@value}。 */ + public static final String CMD_SHOWFIND = "SHOWFIND"; + /** アクション{@value}。 */ + public static final String CMD_SEARCHNEXT = "SEARCHNEXT"; + /** アクション{@value}。 */ + public static final String CMD_SEARCHPREV = "SEARCHPREV"; + /** アクション{@value}。 */ + public static final String CMD_ALLPERIOD = "ALLPERIOD"; + /** アクション{@value}。 */ + public static final String CMD_SHOWDIGEST = "DIGEST"; + /** アクション{@value}。 */ + public static final String CMD_WEBVILL = "WEBVILL"; + /** アクション{@value}。 */ + public static final String CMD_WEBCAST = "WEBCAST"; + /** アクション{@value}。 */ + public static final String CMD_WEBWIKI = "WEBWIKI"; + /** アクション{@value}。 */ + public static final String CMD_RELOAD = "RELOAD"; + /** アクション{@value}。 */ + public static final String CMD_DAYSUMMARY = "DAYSUMMARY"; + /** アクション{@value}。 */ + public static final String CMD_DAYEXPCSV = "DAYEXPCSV"; + /** アクション{@value}。 */ + public static final String CMD_WEBDAY = "WEBDAY"; + /** アクション{@value}。 */ + public static final String CMD_OPTION = "OPTION"; + /** アクション{@value}。 */ + public static final String CMD_LANDF = "LANDF"; + /** アクション{@value}。 */ + public static final String CMD_SHOWFILT = "SHOWFILT"; + /** アクション{@value}。 */ + public static final String CMD_SHOWEDIT = "SHOWEDIT"; + /** アクション{@value}。 */ + public static final String CMD_SHOWLOG = "SHOWLOG"; + /** アクション{@value}。 */ + public static final String CMD_HELPDOC = "HELPDOC"; + /** アクション{@value}。 */ + public static final String CMD_SHOWPORTAL = "SHOWPORTAL"; + /** アクション{@value}。 */ + public static final String CMD_ABOUT = "ABOUT"; + + /** アクション{@value}。 */ + public static final String CMD_COPYTALK = "COPYTALK"; + /** アクション{@value}。 */ + public static final String CMD_JUMPANCHOR = "JUMPANCHOR"; + /** アクション{@value}。 */ + public static final String CMD_WEBTALK = "WEBTALK"; + /** アクション{@value}。 */ + public static final String CMD_SWITCHORDER = "SWITCHORDER"; + /** アクション{@value}。 */ + public static final String CMD_VILLAGELIST = "VILLAGELIST"; + /** アクション{@value}。 */ + public static final String CMD_FONTSIZESEL = "FONTSIZESEL"; + + private static final KeyStroke KEY_F1 = KeyStroke.getKeyStroke("F1"); + private static final KeyStroke KEY_F3 = KeyStroke.getKeyStroke("F3"); + private static final KeyStroke KEY_SHIFT_F3 = + KeyStroke.getKeyStroke("shift F3"); + private static final KeyStroke KEY_F5 = KeyStroke.getKeyStroke("F5"); + private static final KeyStroke KEY_CTRL_F = + KeyStroke.getKeyStroke("ctrl F"); + + /** WWWアイコン。 */ + public static final Icon ICON_WWW = GUIUtils.getWWWIcon(); + /** 検索アイコン。 */ + public static final Icon ICON_FIND; + /** 前検索アイコン。 */ + public static final Icon ICON_SEARCH_PREV; + /** 次検索アイコン。 */ + public static final Icon ICON_SEARCH_NEXT; + /** リロードアイコン。 */ + public static final Icon ICON_RELOAD; + /** フィルタアイコン。 */ + public static final Icon ICON_FILTER; + /** 発言エディタアイコン。 */ + public static final Icon ICON_EDITOR; + + static{ + URL iconurl; + + iconurl = Jindolf.getResource("resources/image/find.png"); + ICON_FIND = new ImageIcon(iconurl); + + iconurl = Jindolf.getResource("resources/image/findprev.png"); + ICON_SEARCH_PREV = new ImageIcon(iconurl); + + iconurl = Jindolf.getResource("resources/image/findnext.png"); + ICON_SEARCH_NEXT = new ImageIcon(iconurl); + + iconurl = Jindolf.getResource("resources/image/reload.png"); + ICON_RELOAD = new ImageIcon(iconurl); + + iconurl = Jindolf.getResource("resources/image/filter.png"); + ICON_FILTER = new ImageIcon(iconurl); + + iconurl = Jindolf.getResource("resources/image/editor.png"); + ICON_EDITOR = new ImageIcon(iconurl); + } + + private final Set actionItems = + new HashSet(); + private final Map namedMenuItems = + new HashMap(); + private final Map namedToolButtons = + new HashMap(); + + private final JMenuBar menuBar; + + private final JMenu menuFile; + private final JMenu menuEdit; + private final JMenu menuVillage; + private final JMenu menuDay; + private final JMenu menuPreference; + private final JMenu menuTool; + private final JMenu menuHelp; + + private final JMenu menuLook; + private final ButtonGroup landfGroup = new ButtonGroup(); + private final Map landfMap = + new HashMap(); + + private final JToolBar browseToolBar; + + /** + * コンストラクタ。 + */ + public ActionManager(){ + this.menuFile = buildMenu("ファイル", KeyEvent.VK_F); + this.menuEdit = buildMenu("編集", KeyEvent.VK_E); + this.menuVillage = buildMenu("村", KeyEvent.VK_V); + this.menuDay = buildMenu("日", KeyEvent.VK_D); + this.menuPreference = buildMenu("設定", KeyEvent.VK_P); + this.menuTool = buildMenu("ツール", KeyEvent.VK_T); + this.menuHelp = buildMenu("ヘルプ", KeyEvent.VK_H); + + this.menuLook = buildLookAndFeelMenu("ルック&フィール", KeyEvent.VK_L); + + buildMenuItem(CMD_ACCOUNT, "アカウント管理", KeyEvent.VK_M); + buildMenuItem(CMD_EXIT, "終了", KeyEvent.VK_X); + buildMenuItem(CMD_COPY, "選択範囲をコピー", KeyEvent.VK_C); + buildMenuItem(CMD_SHOWFIND, "検索...", KeyEvent.VK_F); + buildMenuItem(CMD_SEARCHNEXT, "次候補", KeyEvent.VK_N); + buildMenuItem(CMD_SEARCHPREV, "前候補", KeyEvent.VK_P); + buildMenuItem(CMD_ALLPERIOD, "全日程の一括読み込み", KeyEvent.VK_R); + buildMenuItem(CMD_SHOWDIGEST, "村のダイジェストを表示...", + KeyEvent.VK_D); + buildMenuItem(CMD_WEBVILL, "この村をブラウザで表示...", KeyEvent.VK_N); + buildMenuItem(CMD_WEBWIKI, + "まとめサイトの村ページを表示...", KeyEvent.VK_M); + buildMenuItem(CMD_WEBCAST, "キャスト紹介表ジェネレータ...", + KeyEvent.VK_H); + buildMenuItem(CMD_RELOAD, "この日を強制リロード", KeyEvent.VK_R); + buildMenuItem(CMD_DAYSUMMARY, "この日の発言を集計...", KeyEvent.VK_D); + buildMenuItem(CMD_DAYEXPCSV, "CSVへエクスポート...", KeyEvent.VK_C); + buildMenuItem(CMD_WEBDAY, "この日をブラウザで表示...", KeyEvent.VK_B); + buildMenuItem(CMD_OPTION, "オプション...", KeyEvent.VK_O); + buildMenuItem(CMD_SHOWFILT, "発言フィルタ", KeyEvent.VK_F); + buildMenuItem(CMD_SHOWEDIT, "発言エディタ", KeyEvent.VK_E); + buildMenuItem(CMD_SHOWLOG, "ログ表示", KeyEvent.VK_S); + buildMenuItem(CMD_HELPDOC, "ヘルプ表示", KeyEvent.VK_H); + buildMenuItem(CMD_SHOWPORTAL, "ポータルサイト...", KeyEvent.VK_P); + buildMenuItem(CMD_ABOUT, Jindolf.TITLE + "について...", KeyEvent.VK_A); + + buildToolButton(CMD_RELOAD, "選択中の日を強制リロード", ICON_RELOAD); + buildToolButton(CMD_SHOWFIND, "検索", ICON_FIND); + buildToolButton(CMD_SEARCHPREV, "↑前候補", ICON_SEARCH_PREV); + buildToolButton(CMD_SEARCHNEXT, "↓次候補", ICON_SEARCH_NEXT); + buildToolButton(CMD_SHOWFILT, "発言フィルタ", ICON_FILTER); + buildToolButton(CMD_SHOWEDIT, "発言エディタ", ICON_EDITOR); + + getMenuItem(CMD_SHOWPORTAL).setIcon(ICON_WWW); + getMenuItem(CMD_WEBVILL) .setIcon(ICON_WWW); + getMenuItem(CMD_WEBWIKI) .setIcon(ICON_WWW); + getMenuItem(CMD_WEBCAST) .setIcon(ICON_WWW); + getMenuItem(CMD_WEBDAY) .setIcon(ICON_WWW); + getMenuItem(CMD_SHOWFIND) .setIcon(ICON_FIND); + getMenuItem(CMD_SEARCHPREV).setIcon(ICON_SEARCH_PREV); + getMenuItem(CMD_SEARCHNEXT).setIcon(ICON_SEARCH_NEXT); + getMenuItem(CMD_SHOWFILT) .setIcon(ICON_FILTER); + getMenuItem(CMD_SHOWEDIT) .setIcon(ICON_EDITOR); + + registKeyAccelerator(); + + this.menuBar = buildMenuBar(); + this.browseToolBar = buildBrowseToolBar(); + + appearVillageImpl(false); + appearPeriodImpl(false); + + return; + } + + /** + * メニューを生成する。 + * @param label メニューラベル + * @param nemonic ニモニックキー + * @return メニュー + */ + private JMenu buildMenu(String label, int nemonic){ + JMenu result = new JMenu(); + + String keyText = label + "(" + KeyEvent.getKeyText(nemonic) + ")"; + + result.setText(keyText); + result.setMnemonic(nemonic); + + return result; + } + + /** + * メニューアイテムを生成する。 + * @param command アクションコマンド名 + * @param label メニューラベル + * @param nemonic ニモニックキー + * @return メニューアイテム + */ + private JMenuItem buildMenuItem(String command, + String label, + int nemonic ){ + JMenuItem result = new JMenuItem(); + + String keyText = label + "(" + KeyEvent.getKeyText(nemonic) + ")"; + + result.setActionCommand(command); + result.setText(keyText); + result.setMnemonic(nemonic); + + this.actionItems.add(result); + this.namedMenuItems.put(command, result); + + return result; + } + + /** + * ツールボタンを生成する。 + * @param command アクションコマンド名 + * @param tooltip ツールチップ文字列 + * @param icon アイコン画像 + * @return ツールボタン + */ + private JButton buildToolButton(String command, + String tooltip, + Icon icon){ + JButton result = new JButton(); + + result.setIcon(icon); + result.setToolTipText(tooltip); + result.setMargin(new Insets(1, 1, 1, 1)); + result.setActionCommand(command); + + this.actionItems.add(result); + this.namedToolButtons.put(command, result); + + return result; + } + + /** + * L&F 一覧メニューを作成する。 + * @param label メニューラベル + * @param nemonic ニモニックキー + * @return L&F 一覧メニュー + */ + private JMenu buildLookAndFeelMenu(String label, int nemonic){ + JMenu result = buildMenu(label, nemonic); + + LookAndFeel currentLookAndFeel = UIManager.getLookAndFeel(); + String currentName = currentLookAndFeel.getClass().getName(); + JMenuItem matchedButton = null; + + UIManager.LookAndFeelInfo[] landfs = + UIManager.getInstalledLookAndFeels(); + for(UIManager.LookAndFeelInfo lafInfo : landfs){ + String name = lafInfo.getName(); + String className = lafInfo.getClassName(); + + JRadioButtonMenuItem button = new JRadioButtonMenuItem(name); + button.setActionCommand(CMD_LANDF); + + if(className.equals(currentName)) matchedButton = button; + + this.actionItems.add(button); + this.landfGroup.add(button); + this.landfMap.put(button.getModel(), className); + + result.add(button); + } + + if(matchedButton != null) matchedButton.setSelected(true); + + return result; + } + + /** + * アクセラレータの設定。 + */ + private void registKeyAccelerator(){ + getMenuItem(CMD_HELPDOC) .setAccelerator(KEY_F1); + getMenuItem(CMD_SHOWFIND) .setAccelerator(KEY_CTRL_F); + getMenuItem(CMD_SEARCHNEXT) .setAccelerator(KEY_F3); + getMenuItem(CMD_SEARCHPREV) .setAccelerator(KEY_SHIFT_F3); + getMenuItem(CMD_RELOAD) .setAccelerator(KEY_F5); + return; + } + + /** + * アクションコマンド名からメニューアイテムを探す。 + * @param command アクションコマンド名 + * @return メニューアイテム + */ + private JMenuItem getMenuItem(String command){ + JMenuItem result = this.namedMenuItems.get(command); + return result; + } + + /** + * アクションコマンド名からツールボタンを探す。 + * @param command アクションコマンド名 + * @return ツールボタン + */ + private JButton getToolButton(String command){ + JButton result = this.namedToolButtons.get(command); + return result; + } + + /** + * 現在メニューで選択中のL&Fのクラス名を返す。 + * @return L&F クラス名 + */ + public String getSelectedLookAndFeel(){ + ButtonModel selected = this.landfGroup.getSelection(); + if(selected == null) return null; + String className = this.landfMap.get(selected); + return className; + } + + /** + * 全てのボタンにアクションリスナーを登録する。 + * @param listener アクションリスナー + */ + public void addActionListener(ActionListener listener){ + for(AbstractButton button : this.actionItems){ + button.addActionListener(listener); + } + return; + } + + /** + * メニューバーを生成する。 + * @return メニューバー + */ + private JMenuBar buildMenuBar(){ + this.menuFile.add(getMenuItem(CMD_ACCOUNT)); + this.menuFile.addSeparator(); + this.menuFile.add(getMenuItem(CMD_EXIT)); + + this.menuEdit.add(getMenuItem(CMD_COPY)); + this.menuEdit.addSeparator(); + this.menuEdit.add(getMenuItem(CMD_SHOWFIND)); + this.menuEdit.add(getMenuItem(CMD_SEARCHPREV)); + this.menuEdit.add(getMenuItem(CMD_SEARCHNEXT)); + + this.menuVillage.add(getMenuItem(CMD_ALLPERIOD)); + this.menuVillage.add(getMenuItem(CMD_SHOWDIGEST)); + this.menuVillage.addSeparator(); + this.menuVillage.add(getMenuItem(CMD_WEBVILL)); + this.menuVillage.add(getMenuItem(CMD_WEBWIKI)); + this.menuVillage.add(getMenuItem(CMD_WEBCAST)); + + this.menuDay.add(getMenuItem(CMD_RELOAD)); + this.menuDay.add(getMenuItem(CMD_DAYSUMMARY)); + this.menuDay.add(getMenuItem(CMD_DAYEXPCSV)); + this.menuDay.addSeparator(); + this.menuDay.add(getMenuItem(CMD_WEBDAY)); + + this.menuPreference.add(getMenuItem(CMD_OPTION)); + this.menuPreference.addSeparator(); + this.menuPreference.add(this.menuLook); + + this.menuTool.add(getMenuItem(CMD_SHOWFILT)); + this.menuTool.add(getMenuItem(CMD_SHOWEDIT)); + this.menuTool.add(getMenuItem(CMD_SHOWLOG)); + + this.menuHelp.add(getMenuItem(CMD_HELPDOC)); + this.menuHelp.addSeparator(); + this.menuHelp.add(getMenuItem(CMD_SHOWPORTAL)); + this.menuHelp.add(getMenuItem(CMD_ABOUT)); + + JMenuBar bar = new JMenuBar(); + + bar.add(this.menuFile); + bar.add(this.menuEdit); + bar.add(this.menuVillage); + bar.add(this.menuDay); + bar.add(this.menuPreference); + bar.add(this.menuTool); + bar.add(this.menuHelp); + + return bar; + } + + /** + * メニューバーを取得する。 + * @return メニューバー + */ + public JMenuBar getMenuBar(){ + return this.menuBar; + } + + /** + * ブラウザ用ツールバーの生成を行う。 + * @return ツールバー + */ + private JToolBar buildBrowseToolBar(){ + JToolBar toolBar = new JToolBar(); + + toolBar.add(getToolButton(CMD_RELOAD)); + toolBar.addSeparator(); + toolBar.add(getToolButton(CMD_SHOWFIND)); + toolBar.add(getToolButton(CMD_SEARCHNEXT)); + toolBar.add(getToolButton(CMD_SEARCHPREV)); + toolBar.addSeparator(); + toolBar.add(getToolButton(CMD_SHOWFILT)); + toolBar.add(getToolButton(CMD_SHOWEDIT)); + + return toolBar; + } + + /** + * ブラウザ用ツールバーを取得する。 + * @return ツールバー + */ + public JToolBar getBrowseToolBar(){ + return this.browseToolBar; + } + + /** + * Periodが表示されているか通知を受ける。 + * @param appear 表示されているときはtrue + */ + private void appearPeriodImpl(boolean appear){ + if(appear) appearVillageImpl(appear); + + this.menuEdit.setEnabled(appear); + this.menuDay .setEnabled(appear); + + getToolButton(CMD_RELOAD) .setEnabled(appear); + getToolButton(CMD_SHOWFIND) .setEnabled(appear); + getToolButton(CMD_SEARCHNEXT) .setEnabled(appear); + getToolButton(CMD_SEARCHPREV) .setEnabled(appear); + + return; + } + + /** + * Periodが表示されているか通知を受ける。 + * @param appear 表示されているときはtrue + */ + public void appearPeriod(boolean appear){ + appearPeriodImpl(appear); + return; + } + + /** + * 村が表示されているか通知を受ける。 + * @param appear 表示されているときはtrue + */ + private void appearVillageImpl(boolean appear){ + if( ! appear) appearPeriodImpl(appear); + + this.menuVillage.setEnabled(appear); + + return; + } + + /** + * 村が表示されているか通知を受ける。 + * @param appear 表示されているときはtrue + */ + public void appearVillage(boolean appear){ + appearVillageImpl(appear); + return; + } + +} diff --git a/src/main/java/jp/sourceforge/jindolf/Anchor.java b/src/main/java/jp/sourceforge/jindolf/Anchor.java new file mode 100644 index 0000000..21a0d7c --- /dev/null +++ b/src/main/java/jp/sourceforge/jindolf/Anchor.java @@ -0,0 +1,344 @@ +/* + * anchor + * + * Copyright(c) 2008 olyutorskii + * $Id: Anchor.java 1015 2010-03-16 11:21:21Z olyutorskii $ + */ + +package jp.sourceforge.jindolf; + +import java.util.LinkedList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 発言アンカー。 + */ +public final class Anchor{ + + private static final int EPILOGUEDAY = 99; + private static final Pattern ANCHOR_PATTERN; + + static{ + String spchar = "\u0020\u3000\\t"; + String sp = "[" +spchar+ "]"; + String sp_n = "(?:" + sp + "|" + "(?:\\Q \\E)" + ")*?"; + + String day = // TODO 「昨日」なども含めるか? + "(" + +"(?:" + + "(プロ(?:ローグ)?)" + +"|"+"(エピ(?:ローグ)?)" + +"|"+"(?:" + + "([1-91-9]?[0-90-9])" + +sp_n+ "(?:[dDdD]|(?:日目?))" + +")" + +")" +"[\\-\\[\\(/_-ー―[_]?" +sp_n + +")?"; + String ampm = + "(" + +"(?:" + + "((?:[aAaA][\\..]?[mMmM][\\..]?)|(?:午前))" + +"|"+"((?:[pPpP][\\..]?[mMmM][\\..]?)|(?:午後))" + +")" +sp_n + +")?"; + String hhmm = + "(?:"+ + "(" + +"([0-20-2]?[0-90-9])" + +sp_n+ "[:;:;]?" +sp_n + +"([0-50-5][0-90-9])" + +")" + +"|" + +"(" + +"([0-20-2]?[0-90-9])" + +sp_n+ "時" +sp_n + +"([0-50-5]?[0-90-9])" + +sp_n+ "分" + +")" + +")"; + + String talkNum = + "(?:>>([1-9][0-9]*))"; + + ANCHOR_PATTERN = Pattern.compile(day + ampm + hhmm +"|"+ talkNum, + Pattern.DOTALL); + } + + /** + * 与えられた範囲指定文字列からアンカーを抽出する。 + * @param source 検索対象文字列 + * @param regionStart 範囲開始位置 + * @param regionEnd 範囲終了位置 + * @param currentDay 相対日付の基本となる日 + * @return アンカー + */ + public static Anchor getAnchor(CharSequence source, + int regionStart, + int regionEnd, + int currentDay ){ + Matcher matcher = ANCHOR_PATTERN.matcher(source); + matcher.region(regionStart, regionEnd); + + if( ! matcher.find() ) return null; + + Anchor anchor = getAnchorFromMatched(source, matcher, currentDay); + + return anchor; + } + + /** + * 与えられた文字列から全アンカーを抽出する。 + * @param source 検索対象文字列 + * @param currentDay 相対日付の基本となる日 + * @return アンカーのリスト(出現順) + */ + public static List getAnchorList(CharSequence source, + int currentDay ){ + List result = new LinkedList(); + + Matcher matcher = ANCHOR_PATTERN.matcher(source); + int regionEnd = source.length(); + + while(matcher.find()){ + Anchor anchor = getAnchorFromMatched(source, matcher, currentDay); + result.add(anchor); + int regionStart = matcher.end(); + matcher.region(regionStart, regionEnd); + } + + return result; + } + + /** + * 文字列とそのMatcherからアンカーを抽出する。 + * @param source 検索対象文字列 + * @param matcher Matcher + * @param currentDay 相対日付の基本となる日 + * @return アンカー + */ + private static Anchor getAnchorFromMatched(CharSequence source, + Matcher matcher, + int currentDay){ + int startPos = matcher.start(); + int endPos = matcher.end(); + + /* G国アンカー */ + if(matcher.start(14) < matcher.end(14)){ + int talkNo = StringUtils.parseInt(source, matcher, 14); + Anchor anchor = new Anchor(source, startPos, endPos, talkNo); + return anchor; + } + + int day = currentDay; + if(matcher.start(1) < matcher.end(1)){ + if(matcher.start(2) < matcher.end(2)){ // prologue + day = 0; + }else if(matcher.start(3) < matcher.end(3)){ // epilogue + day = EPILOGUEDAY; + }else if(matcher.start(4) < matcher.end(4)){ // etc) "6d" + day = StringUtils.parseInt(source, matcher, 4); + }else{ + assert false; + return null; + } + } + + boolean isPM = false; + if(matcher.start(5) < matcher.end(5)){ + if(matcher.start(6) < matcher.end(6)){ // AM + isPM = false; + }else if(matcher.start(7) < matcher.end(7)){ // PM + isPM = true; + }else{ + assert false; + return null; + } + } + + int hourGroup; + int minuteGroup; + if(matcher.start(8) < matcher.end(8)){ // hhmm hmm hh:mm + hourGroup = 9; + minuteGroup = 10; + }else if(matcher.start(11) < matcher.end(11)){ // h時m分 + hourGroup = 12; + minuteGroup = 13; + }else{ + assert false; + return null; + } + int hour = StringUtils.parseInt(source, matcher, hourGroup); + int minute = StringUtils.parseInt(source, matcher, minuteGroup); + + if(isPM && hour < 12) hour += 12; + hour %= 24; + // 午後12:34は午後00:34になる + + // TODO 3d25:30 は 3d01:30 か 4d01:30 どちらにすべきか? + // とりあえず前者 + + Anchor anchor = new Anchor(source, startPos, endPos, + day, hour, minute); + + return anchor; + } + + private final CharSequence source; + private final int startPos; + private final int endPos; + private final int day; + private final int hour; + private final int minute; + private final int talkNo; + + /** + * アンカーのコンストラクタ。 + * @param source アンカーが含まれる文字列 + * @param startPos アンカーの始まる位置 + * @param endPos アンカーの終わる位置 + * @param day 日 + * @param hour 時間(0-23) + * @param minute 分(0-59) + */ + private Anchor(CharSequence source, int startPos, int endPos, + int day, int hour, int minute ){ + super(); + + this.source = source; + this.startPos = startPos; + this.endPos = endPos; + this.day = day; + this.hour = hour; + this.minute = minute; + this.talkNo = -1; + + return; + } + + /** + * アンカーのコンストラクタ。 + * @param source アンカーが含まれる文字列 + * @param startPos アンカーの始まる位置 + * @param endPos アンカーの終わる位置 + * @param talkNo 公開発言番号 + */ + private Anchor(CharSequence source, int startPos, int endPos, + int talkNo ){ + super(); + + if(talkNo <= 0) throw new IllegalArgumentException(); + + this.source = source; + this.startPos = startPos; + this.endPos = endPos; + this.day = -1; + this.hour = -1; + this.minute = -1; + this.talkNo = talkNo; + + return; + } + + /** + * アンカーの含まれる文字列を返す。 + * @return アンカーの含まれる文字列 + */ + public CharSequence getSource(){ + return this.source; + } + + /** + * アンカーの開始位置を返す。 + * @return アンカー開始位置 + */ + public int getStartPos(){ + return this.startPos; + } + + /** + * アンカーの終了位置を返す。 + * @return アンカー終了位置 + */ + public int getEndPos(){ + return this.endPos; + } + + /** + * アンカーの示す日付を返す。 + * @return 日付 + */ + public int getDay(){ + return this.day; + } + + /** + * アンカーの示す時刻を返す。 + * @return 時刻(0-23) + */ + public int getHour(){ + return this.hour; + } + + /** + * アンカーの示す分を返す。 + * @return 分(0-59) + */ + public int getMinute(){ + return this.minute; + } + + /** + * アンカーの示す公開発言番号を返す。 + * @return 公開発言番号。公開発言番号でない場合は0以下の値。 + */ + public int getTalkNo(){ + return this.talkNo; + } + + /** + * このアンカーが公開発言番号による物か判定する。 + * @return 公開発言番号由来であるならtrue + */ + public boolean hasTalkNo(){ + return 0 < this.talkNo; + } + + /** + * 明示的なエピローグへのアンカーか判定する。 + * @return 明示的なエピローグへのアンカーならtrue + */ + public boolean isEpilogueDay(){ + if(this.day >= EPILOGUEDAY) return true; + return false; + } + + /** + * アンカーの文字列表記を返す。 + * 出典:まとめサイトの用語集 + * @return アンカーの文字列表記 + */ + @Override + public String toString(){ + /* G国表記 */ + if(hasTalkNo()){ + return ">>" + this.talkNo; + } + + StringBuilder result = new StringBuilder(); + + result.append(getDay()).append('d'); + + int anchorHour = getHour(); + if(anchorHour < 10) result.append('0'); + result.append(anchorHour).append(':'); + + int anchorMinute = getMinute(); + if(anchorMinute < 10) result.append('0'); + result.append(anchorMinute); + + return result.toString(); + } + +} diff --git a/src/main/java/jp/sourceforge/jindolf/AnchorDraw.java b/src/main/java/jp/sourceforge/jindolf/AnchorDraw.java new file mode 100644 index 0000000..beb159a --- /dev/null +++ b/src/main/java/jp/sourceforge/jindolf/AnchorDraw.java @@ -0,0 +1,296 @@ +/* + * アンカー描画 + * + * Copyright(c) 2008 olyutorskii + * $Id: AnchorDraw.java 995 2010-03-15 03:54:09Z olyutorskii $ + */ + +package jp.sourceforge.jindolf; + +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.Point; +import java.awt.Rectangle; +import java.awt.Stroke; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.util.regex.Pattern; + +/** + * アンカー描画。 + */ +public class AnchorDraw extends AbstractTextRow{ + + private static final Color COLOR_ANCHOR = new Color(0xffff99); + private static final Color COLOR_SIMPLEFG = Color.BLACK; + private static final int UPPER_MARGIN = 3; + private static final int UNDER_MARGIN = 3; + + private static final Stroke STROKE_DASH; + + static{ + float[] dash = {3.0f}; + STROKE_DASH = new BasicStroke(1.0f, + BasicStroke.CAP_SQUARE, + BasicStroke.JOIN_MITER, + 10.0f, + dash, + 0.0f); + } + + private final Talk talk; + private final GlyphDraw caption; + private final GlyphDraw dialog; + private final BufferedImage faceImage; + private final Point imageOrigin = new Point(); + private final Point captionOrigin = new Point(); + private final Point dialogOrigin = new Point(); + + private DialogPref dialogPref; + + /** + * コンストラクタ。 + * @param talk 発言 + */ + public AnchorDraw(Talk talk){ + this(talk, new DialogPref(), FontInfo.DEFAULT_FONTINFO); + return; + } + + /** + * コンストラクタ。 + * @param talk 発言 + * @param pref 発言表示設定 + * @param fontInfo フォント設定 + */ + public AnchorDraw(Talk talk, DialogPref pref, FontInfo fontInfo){ + super(fontInfo); + + this.talk = talk; + this.caption = new GlyphDraw(getCaptionString(), this.fontInfo); + this.dialog = new GlyphDraw(this.talk.getDialog(), this.fontInfo); + this.dialogPref = pref; + this.faceImage = getFaceImage(); + + setColorDesign(); + + return; + } + + /** + * 顔アイコンイメージを返す。 + * @return アイコンイメージ + */ + private BufferedImage getFaceImage(){ + Period period = this.talk.getPeriod(); + Village village = period.getVillage(); + + BufferedImage image; + if(this.talk.isGrave()){ + image = village.getGraveImage(); + }else{ + Avatar avatar = this.talk.getAvatar(); + image = village.getAvatarFaceImage(avatar); + } + + return image; + } + + /** + * 配色を設定する。 + */ + private void setColorDesign(){ + Color fgColor; + if(this.dialogPref.isSimpleMode()){ + fgColor = COLOR_SIMPLEFG; + }else{ + fgColor = COLOR_ANCHOR; + } + this.caption.setColor(fgColor); + this.dialog .setColor(fgColor); + return; + } + + /** + * キャプション文字列の取得。 + * @return キャプション文字列 + */ + private CharSequence getCaptionString(){ + StringBuilder result = new StringBuilder(); + Avatar avatar = this.talk.getAvatar(); + + if(this.talk.hasTalkNo()){ + result.append(this.talk.getAnchorNotation_G()).append(' '); + } + result.append(avatar.getFullName()) + .append(' ') + .append(this.talk.getAnchorNotation()); + + return result; + } + + /** + * {@inheritDoc} + * @return {@inheritDoc} + */ + public Rectangle recalcBounds(){ + int newWidth = getWidth(); + + int imageWidth = 0; + int imageHeight = 0; + if( ! this.dialogPref.isSimpleMode() ){ + imageWidth = this.faceImage.getWidth(null); + imageHeight = this.faceImage.getHeight(null); + } + + this.caption.setWidth(newWidth - imageWidth); + int captionWidth = this.caption.getWidth(); + int captionHeight = this.caption.getHeight(); + + this.dialog.setWidth(newWidth); + int dialogWidth = this.dialog.getWidth(); + int dialogHeight = this.dialog.getHeight(); + + int headerHeight = Math.max(imageHeight, captionHeight); + + int totalWidth = Math.max(imageWidth + captionWidth, dialogWidth); + + int totalHeight = headerHeight; + totalHeight += dialogHeight; + + int imageYpos; + int captionYpos; + if(imageHeight > captionHeight){ + imageYpos = 0; + captionYpos = (imageHeight - captionHeight) / 2; + }else{ + imageYpos = (captionHeight - imageHeight) / 2; + captionYpos = 0; + } + + this.imageOrigin .setLocation(0, + UPPER_MARGIN + imageYpos); + this.captionOrigin.setLocation(imageWidth, + UPPER_MARGIN + captionYpos); + this.dialogOrigin .setLocation(0, + UPPER_MARGIN + headerHeight); + + if(this.dialogPref.isSimpleMode()){ + this.bounds.width = newWidth; + }else{ + this.bounds.width = totalWidth; + } + this.bounds.height = UPPER_MARGIN + totalHeight + UNDER_MARGIN; + + return this.bounds; + } + + /** + * {@inheritDoc} + * @param fontInfo {@inheritDoc} + */ + @Override + public void setFontInfo(FontInfo fontInfo){ + super.setFontInfo(fontInfo); + + this.caption.setFontInfo(this.fontInfo); + this.dialog .setFontInfo(this.fontInfo); + + recalcBounds(); + + return; + } + + /** + * 発言設定を更新する。 + * @param pref 発言設定 + */ + public void setDialogPref(DialogPref pref){ + this.dialogPref = pref; + + setColorDesign(); + recalcBounds(); + + return; + } + + /** + * {@inheritDoc} + * @param from {@inheritDoc} + * @param to {@inheritDoc} + */ + public void drag(Point from, Point to){ + this.caption.drag(from, to); + this.dialog.drag(from, to); + return; + } + + /** + * {@inheritDoc} + * @param appendable {@inheritDoc} + * @return {@inheritDoc} + * @throws java.io.IOException {@inheritDoc} + */ + public Appendable appendSelected(Appendable appendable) + throws IOException{ + this.caption.appendSelected(appendable); + this.dialog.appendSelected(appendable); + return appendable; + } + + /** + * {@inheritDoc} + */ + public void clearSelect(){ + this.caption.clearSelect(); + this.dialog.clearSelect(); + return; + } + + /** + * 検索文字列パターンを設定する。 + * @param searchRegex パターン + * @return ヒット数 + */ + public int setRegex(Pattern searchRegex){ + int total = 0; + + total += this.dialog.setRegex(searchRegex); + + return total; + } + + /** + * {@inheritDoc} + * @param g {@inheritDoc} + */ + public void paint(Graphics2D g){ + final int xPos = this.bounds.x; + final int yPos = this.bounds.y; + + if(this.dialogPref.isSimpleMode()){ + Stroke oldStroke = g.getStroke(); + g.setStroke(STROKE_DASH); + g.drawLine(xPos, this.bounds.y, + xPos + this.bounds.width, this.bounds.y ); + g.setStroke(oldStroke); + }else{ + g.drawImage(this.faceImage, + xPos + this.imageOrigin.x, + yPos + this.imageOrigin.y, + null ); + } + + this.caption.setPos(xPos + this.captionOrigin.x, + yPos + this.captionOrigin.y ); + this.caption.paint(g); + + this.dialog.setPos(xPos + this.dialogOrigin.x, + yPos + this.dialogOrigin.y ); + this.dialog.paint(g); + + return; + } + +} diff --git a/src/main/java/jp/sourceforge/jindolf/AnchorHitEvent.java b/src/main/java/jp/sourceforge/jindolf/AnchorHitEvent.java new file mode 100644 index 0000000..8defdfe --- /dev/null +++ b/src/main/java/jp/sourceforge/jindolf/AnchorHitEvent.java @@ -0,0 +1,63 @@ +/* + * anchor hit event + * + * Copyright(c) 2008 olyutorskii + * $Id: AnchorHitEvent.java 888 2009-11-04 06:23:35Z olyutorskii $ + */ + +package jp.sourceforge.jindolf; + +import java.awt.Point; +import java.util.EventObject; + +/** + * 発言アンカーがクリックされたときのイベント。 + */ +@SuppressWarnings("serial") +public class AnchorHitEvent extends EventObject{ + + private final TalkDraw talkDraw; + private final Anchor anchor; + private final Point point; + + /** + * コンストラクタ。 + * @param source イベント発生源 + * @param talkDraw 会話描画コンポーネント + * @param anchor アンカー + * @param point マウス座標 + */ + public AnchorHitEvent(Object source, + TalkDraw talkDraw, Anchor anchor, Point point){ + super(source); + this.talkDraw = talkDraw; + this.anchor = anchor; + this.point = point; + return; + } + + /** + * 会話描画コンポーネントを返す。 + * @return 会話描画コンポーネント + */ + public TalkDraw getTalkDraw(){ + return this.talkDraw; + } + + /** + * アンカーを返す。 + * @return アンカー + */ + public Anchor getAnchor(){ + return this.anchor; + } + + /** + * マウス座標を返す。 + * @return マウス座標 + */ + public Point getPoint(){ + return this.point; + } + +} diff --git a/src/main/java/jp/sourceforge/jindolf/AnchorHitListener.java b/src/main/java/jp/sourceforge/jindolf/AnchorHitListener.java new file mode 100644 index 0000000..32e56c5 --- /dev/null +++ b/src/main/java/jp/sourceforge/jindolf/AnchorHitListener.java @@ -0,0 +1,22 @@ +/* + * anchor hit listener + * + * Copyright(c) 2008 olyutorskii + * $Id: AnchorHitListener.java 888 2009-11-04 06:23:35Z olyutorskii $ + */ + +package jp.sourceforge.jindolf; + +import java.util.EventListener; + +/** + * 発言アンカーがヒットしたときのリスナ。 + */ +public interface AnchorHitListener extends EventListener{ + /** + * アンカーがクリックされたときに呼び出される。 + * @param event イベント + */ + void anchorHitted(AnchorHitEvent event); + +} diff --git a/src/main/java/jp/sourceforge/jindolf/AppSetting.java b/src/main/java/jp/sourceforge/jindolf/AppSetting.java new file mode 100644 index 0000000..bc6b106 --- /dev/null +++ b/src/main/java/jp/sourceforge/jindolf/AppSetting.java @@ -0,0 +1,416 @@ +/* + * application settings + * + * Copyright(c) 2008 olyutorskii + * $Id: AppSetting.java 977 2010-01-02 15:54:12Z olyutorskii $ + */ + +package jp.sourceforge.jindolf; + +import java.awt.Font; +import java.awt.font.FontRenderContext; +import java.io.File; +import jp.sourceforge.jindolf.json.JsBoolean; +import jp.sourceforge.jindolf.json.JsObject; +import jp.sourceforge.jindolf.json.JsPair; +import jp.sourceforge.jindolf.json.JsValue; + +/** + * アプリケーションの各種設定。 + */ +public class AppSetting{ + + private static final String NETCONFIG_FILE = "netconfig.json"; + private static final String HASH_PROXY = "proxy"; + + private static final String TALKCONFIG_FILE = "talkconfig.json"; + private static final String HASH_FONT = "font"; + private static final String HASH_USEBODYICON = "useBodyIcon"; + private static final String HASH_USEMONOTOMB = "useMonoTomb"; + private static final String HASH_SIMPLEMODE = "isSimpleMode"; + private static final String HASH_ALIGNBALOON = "alignBaloonWidth"; + + private OptionInfo optInfo; + + private boolean useConfigPath; + private File configPath; + + private FontInfo fontInfo = FontInfo.DEFAULT_FONTINFO; + + private int frameWidth = 800; + private int frameHeight = 600; + private int frameXpos = Integer.MIN_VALUE; + private int frameYpos = Integer.MIN_VALUE; + + private ProxyInfo proxyInfo = ProxyInfo.DEFAULT; + + private DialogPref dialogPref = new DialogPref(); + + private JsValue loadedNetConfig; + private JsValue loadedTalkConfig; + + /** + * コンストラクタ。 + */ + public AppSetting(){ + super(); + return; + } + + /** + * コマンドラインオプションからアプリ設定を展開する。 + * @param optionInfo オプション情報 + */ + public void applyOptionInfo(OptionInfo optionInfo){ + this.optInfo = optionInfo; + applyConfigPathSetting(); + applyFontSetting(); + applyGeometrySetting(); + return; + } + + /** + * 設定格納ディレクトリ関係の設定。 + */ + private void applyConfigPathSetting(){ + CmdOption opt = this.optInfo + .getExclusiveOption(CmdOption.OPT_CONFDIR, + CmdOption.OPT_NOCONF ); + if(opt == CmdOption.OPT_NOCONF){ + this.useConfigPath = false; + this.configPath = null; + }else if(opt == CmdOption.OPT_CONFDIR){ + this.useConfigPath = true; + String path = this.optInfo.getStringArg(CmdOption.OPT_CONFDIR); + this.configPath = FileUtils.supplyFullPath(new File(path)); + }else{ + this.useConfigPath = true; + File path = ConfigFile.getImplicitConfigDirectory(); + this.configPath = path; + } + + return; + } + + /** + * フォント関係の設定。 + */ + private void applyFontSetting(){ + String fontName = this.optInfo.getStringArg(CmdOption.OPT_INITFONT); + Boolean useAntiAlias = + this.optInfo.getBooleanArg(CmdOption.OPT_ANTIALIAS); + Boolean useFractional = + this.optInfo.getBooleanArg(CmdOption.OPT_FRACTIONAL); + + if(fontName != null){ + Font font = Font.decode(fontName); + this.fontInfo = this.fontInfo.deriveFont(font); + } + + if(useAntiAlias != null){ + FontRenderContext context = this.fontInfo.getFontRenderContext(); + FontRenderContext newContext = + new FontRenderContext(context.getTransform(), + useAntiAlias.booleanValue(), + context.usesFractionalMetrics() ); + this.fontInfo = this.fontInfo.deriveRenderContext(newContext); + } + + if(useFractional != null){ + FontRenderContext context = this.fontInfo.getFontRenderContext(); + FontRenderContext newContext = + new FontRenderContext(context.getTransform(), + context.isAntiAliased(), + useFractional.booleanValue() ); + this.fontInfo = this.fontInfo.deriveRenderContext(newContext); + } + + return; + } + + /** + * ジオメトリ関係の設定。 + */ + private void applyGeometrySetting(){ + Integer ival; + + ival = this.optInfo.initialFrameWidth(); + if(ival != null) this.frameWidth = ival; + + ival = this.optInfo.initialFrameHeight(); + if(ival != null) this.frameHeight = ival; + + ival = this.optInfo.initialFrameXpos(); + if(ival != null) this.frameXpos = ival; + + ival = this.optInfo.initialFrameYpos(); + if(ival != null) this.frameYpos = ival; + + return; + } + + /** + * 設定格納ディレクトリを返す。 + * @return 設定格納ディレクトリ。 + */ + public File getConfigPath(){ + return this.configPath; + } + + /** + * 設定格納ディレクトリを設定する。 + * @param path 設定格納ディレクトリ + */ + public void setConfigPath(File path){ + this.configPath = path; + return; + } + + /** + * 設定格納ディレクトリを使うか否かを返す。 + * @return 使うならtrue + */ + public boolean useConfigPath(){ + return this.useConfigPath; + } + + /** + * 設定格納ディレクトリを使うか否か設定する。 + * @param need 使うならtrue + */ + public void setUseConfigPath(boolean need){ + this.useConfigPath = need; + return; + } + + /** + * 初期のフレーム幅を返す。 + * @return 初期のフレーム幅 + */ + public int initialFrameWidth(){ + return this.frameWidth; + } + + /** + * 初期のフレーム高を返す。 + * @return 初期のフレーム高 + */ + public int initialFrameHeight(){ + return this.frameHeight; + } + + /** + * 初期のフレーム位置のX座標を返す。 + * 特に指示されていなければInteger.MIN_VALUEを返す。 + * @return 初期のフレーム位置のX座標 + */ + public int initialFrameXpos(){ + return this.frameXpos; + } + + /** + * 初期のフレーム位置のY座標を返す。 + * 特に指示されていなければInteger.MIN_VALUEを返す。 + * @return 初期のフレーム位置のY座標 + */ + public int initialFrameYpos(){ + return this.frameYpos; + } + + /** + * フォント設定を返す。 + * @return フォント設定 + */ + public FontInfo getFontInfo(){ + return this.fontInfo; + } + + /** + * フォント設定を更新する。 + * @param fontInfo フォント設定 + */ + public void setFontInfo(FontInfo fontInfo){ + this.fontInfo = fontInfo; + return; + } + + /** + * プロクシ設定を返す。 + * @return プロクシ設定 + */ + public ProxyInfo getProxyInfo(){ + return this.proxyInfo; + } + + /** + * プロクシ設定を更新する。 + * @param proxyInfo プロクシ設定。nullならプロクシなしと解釈される。 + */ + public void setProxyInfo(ProxyInfo proxyInfo){ + if(proxyInfo == null) this.proxyInfo = ProxyInfo.DEFAULT; + else this.proxyInfo = proxyInfo; + return; + } + + /** + * 発言表示設定を返す。 + * @return 表示設定 + */ + public DialogPref getDialogPref(){ + return this.dialogPref; + } + + /** + * 発言表示設定を返す。 + * @param pref 表示設定 + */ + public void setDialogPref(DialogPref pref){ + if(pref == null) this.dialogPref = new DialogPref(); + else this.dialogPref = pref; + return; + } + + /** + * ネットワーク設定をロードする。 + */ + private void loadNetConfig(){ + if( ! useConfigPath() ) return; + + JsValue value = ConfigFile.loadJson(new File(NETCONFIG_FILE)); + if(value == null) return; + this.loadedNetConfig = value; + + if( ! (value instanceof JsObject) ) return; + JsObject root = (JsObject) value; + + value = root.getValue(HASH_PROXY); + if( ! (value instanceof JsObject) ) return; + JsObject proxy = (JsObject) value; + + ProxyInfo info = ProxyInfo.decodeJson(proxy); + + setProxyInfo(info); + + return; + } + + /** + * 会話表示設定をロードする。 + */ + private void loadTalkConfig(){ + if( ! useConfigPath() ) return; + + JsValue value = ConfigFile.loadJson(new File(TALKCONFIG_FILE)); + if(value == null) return; + this.loadedTalkConfig = value; + + if( ! (value instanceof JsObject) ) return; + JsObject root = (JsObject) value; + + value = root.getValue(HASH_FONT); + if(value instanceof JsObject){ + JsObject font = (JsObject) value; + FontInfo info = FontInfo.decodeJson(font); + setFontInfo(info); + applyFontSetting(); + } + + DialogPref pref = new DialogPref(); + JsBoolean boolValue; + value = root.getValue(HASH_USEBODYICON); + if(value instanceof JsBoolean){ + boolValue = (JsBoolean) value; + pref.setBodyImageSetting(boolValue.booleanValue()); + } + value = root.getValue(HASH_USEMONOTOMB); + if(value instanceof JsBoolean){ + boolValue = (JsBoolean) value; + pref.setMonoImageSetting(boolValue.booleanValue()); + } + value = root.getValue(HASH_SIMPLEMODE); + if(value instanceof JsBoolean){ + boolValue = (JsBoolean) value; + pref.setSimpleMode(boolValue.booleanValue()); + } + value = root.getValue(HASH_ALIGNBALOON); + if(value instanceof JsBoolean){ + boolValue = (JsBoolean) value; + pref.setAlignBalooonWidthSetting(boolValue.booleanValue()); + } + setDialogPref(pref); + + return; + } + + /** + * ネットワーク設定をセーブする。 + */ + private void saveNetConfig(){ + if( ! useConfigPath() ) return; + + JsObject root = new JsObject(); + JsObject proxy = ProxyInfo.buildJson(getProxyInfo()); + root.putValue(HASH_PROXY, proxy); + + if(this.loadedNetConfig != null){ + if(this.loadedNetConfig.equals(root)) return; + } + + ConfigFile.saveJson(new File(NETCONFIG_FILE), root); + + return; + } + + /** + * 会話表示設定をセーブする。 + */ + private void saveTalkConfig(){ + if( ! useConfigPath() ) return; + + JsObject root = new JsObject(); + + JsObject font = FontInfo.buildJson(getFontInfo()); + root.putValue(HASH_FONT, font); + + DialogPref pref = getDialogPref(); + JsPair useBodyIcon = + new JsPair(HASH_USEBODYICON, pref.useBodyImage()); + JsPair useMonoTomb = + new JsPair(HASH_USEMONOTOMB, pref.useMonoImage()); + JsPair isSimple = + new JsPair(HASH_SIMPLEMODE, pref.isSimpleMode()); + JsPair alignBaloon = + new JsPair(HASH_ALIGNBALOON, pref.alignBaloonWidth()); + root.putPair(useBodyIcon); + root.putPair(useMonoTomb); + root.putPair(isSimple); + root.putPair(alignBaloon); + + if(this.loadedTalkConfig != null){ + if(this.loadedTalkConfig.equals(root)) return; + } + + ConfigFile.saveJson(new File(TALKCONFIG_FILE), root); + + return; + } + + /** + * 各種設定を設定格納ディレクトリからロードする。 + */ + public void loadConfig(){ + loadNetConfig(); + loadTalkConfig(); + return; + } + + /** + * 各種設定を設定格納ディレクトリへセーブする。 + */ + public void saveConfig(){ + saveNetConfig(); + saveTalkConfig(); + return; + } + +} diff --git a/src/main/java/jp/sourceforge/jindolf/Avatar.java b/src/main/java/jp/sourceforge/jindolf/Avatar.java new file mode 100644 index 0000000..882dec7 --- /dev/null +++ b/src/main/java/jp/sourceforge/jindolf/Avatar.java @@ -0,0 +1,298 @@ +/* + * characters in village + * + * Copyright(c) 2008 olyutorskii + * $Id: Avatar.java 972 2009-12-26 05:05:15Z olyutorskii $ + */ + +package jp.sourceforge.jindolf; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.RandomAccess; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import javax.xml.parsers.DocumentBuilder; +import jp.sourceforge.jindolf.corelib.PreDefAvatar; + +/** + * Avatar またの名をキャラクター。 + */ +public class Avatar implements Comparable { + + private static final List AVATAR_LIST; + private static final Map AVATAR_MAP; + + private static final Pattern AVATAR_PATTERN; + + /** ゲルト。 */ + public static final Avatar AVATAR_GERD; + + static{ + List predefs; + try{ + DocumentBuilder builder = XmlUtils.createDocumentBuilder(); + predefs = PreDefAvatar.buildPreDefAvatarList(builder); + }catch(RuntimeException e){ + throw e; + }catch(Exception e){ + throw new ExceptionInInitializerError(e); + } + + AVATAR_LIST = buildAvatarList(predefs); + + AVATAR_MAP = new HashMap(); + for(Avatar avatar : AVATAR_LIST){ + String fullName = avatar.getFullName(); + AVATAR_MAP.put(fullName, avatar); + } + + StringBuilder avatarGroupRegex = new StringBuilder(); + for(Avatar avatar : AVATAR_LIST){ + String fullName = avatar.getFullName(); + if(avatarGroupRegex.length() > 0){ + avatarGroupRegex.append('|'); + } + avatarGroupRegex.append('(') + .append(Pattern.quote(fullName)) + .append(')'); + } + AVATAR_PATTERN = Pattern.compile(avatarGroupRegex.toString()); + + AVATAR_GERD = getPredefinedAvatar("楽天家 ゲルト"); + + assert AVATAR_LIST instanceof RandomAccess; + assert AVATAR_GERD != null; + } + + /** + * 定義済みAvatar群の生成。 + * @param predefs 定義済みAvatar元データ群 + * @return ソートされた定義済みAvatarのリスト + */ + private static List buildAvatarList(List predefs){ + List result = new ArrayList(predefs.size()); + + for(PreDefAvatar preDefAvatar : predefs){ + String shortName = preDefAvatar.getShortName(); + String jobTitle = preDefAvatar.getJobTitle(); + int serialNo = preDefAvatar.getSerialNo(); + String avatarId = preDefAvatar.getAvatarId(); + Avatar avatar = new Avatar(shortName, + jobTitle, + serialNo, + avatarId ); + result.add(avatar); + } + + Collections.sort(result); + result = Collections.unmodifiableList(result); + + return result; + } + + /** + * 定義済みAvatar群のリストを返す。 + * @return Avatarのリスト + */ + public static List getPredefinedAvatarList(){ + return AVATAR_LIST; + } + + /** + * 定義済みAvatarを返す。 + * @param fullName Avatarのフルネーム + * @return Avatar。フルネームが一致するAvatarが無ければnull + */ + // TODO 20キャラ程度ならListをなめる方が早いか? + public static Avatar getPredefinedAvatar(String fullName){ + return AVATAR_MAP.get(fullName); + } + + /** + * 定義済みAvatarを返す。 + * @param fullName Avatarのフルネーム + * @return Avatar。フルネームが一致するAvatarが無ければnull + */ + public static Avatar getPredefinedAvatar(CharSequence fullName){ + for(Avatar avatar : AVATAR_LIST){ + String avatarName = avatar.getFullName(); + if(avatarName.contentEquals(fullName)){ + return avatar; + } + } + return null; + } + + /** + * 定義済みAvatar名に一致しないか調べる。 + * @param matcher マッチャ + * @return 一致したAvatar。一致しなければnull。 + */ + public static Avatar lookingAtAvatar(Matcher matcher){ + matcher.usePattern(AVATAR_PATTERN); + + if( ! matcher.lookingAt() ) return null; + int groupCt = matcher.groupCount(); + for(int group = 1; group <= groupCt; group++){ + if(matcher.start(group) >= 0){ + Avatar avatar = AVATAR_LIST.get(group - 1); + return avatar; + } + } + + return null; + } + + private final String name; + private final String jobTitle; + private final String fullName; + private final int idNum; + private final String identifier; + private final int hashNum; + + /** + * Avatarを生成する。 + * @param name 名前 + * @param jobTitle 職業名 + * @param idNum 通し番号 + * @param identifier 識別文字列 + */ + private Avatar(String name, + String jobTitle, + int idNum, + String identifier ){ + this.name = name.intern(); + this.jobTitle = jobTitle.intern(); + this.idNum = idNum; + this.identifier = identifier.intern(); + + this.fullName = (this.jobTitle + " " + this.name).intern(); + + this.hashNum = this.fullName.hashCode() ^ this.idNum; + + return; + } + + /** + * Avatarを生成する。 + * @param fullName フルネーム + */ + // TODO 当面は呼ばれないはず。Z国とか向け。 + public Avatar(String fullName){ + this.fullName = fullName.intern(); + this.idNum = -1; + + String[] tokens = this.fullName.split("\\p{Blank}+", 2); + if(tokens.length == 1){ + this.jobTitle = null; + this.name = this.fullName; + }else if(tokens.length == 2){ + this.jobTitle = tokens[0].intern(); + this.name = tokens[1].intern(); + }else{ + this.jobTitle = null; + this.name = null; + assert false; + } + + this.identifier = "???".intern(); + + this.hashNum = this.fullName.hashCode() ^ this.idNum; + + return; + } + + /** + * フルネームを取得する。 + * @return フルネーム + */ + public String getFullName(){ + return this.fullName; + } + + /** + * 職業名を取得する。 + * @return 職業名 + */ + public String getJobTitle(){ + return this.jobTitle; + } + + /** + * 通常名を取得する。 + * @return 通常名 + */ + public String getName(){ + return this.name; + } + + /** + * 通し番号を返す。 + * @return 通し番号 + */ + public int getIdNum(){ + return this.idNum; + } + + /** + * 識別文字列を返す。 + * @return 識別文字列 + */ + public String getIdentifier(){ + return this.identifier; + } + + /** + * {@inheritDoc} + * @param obj {@inheritDoc} + * @return {@inheritDoc} + */ + @Override + public boolean equals(Object obj){ + if(this == obj){ + return true; + } + + if( ! (obj instanceof Avatar) ){ + return false; + } + Avatar other = (Avatar) obj; + + boolean nameMatch = this.fullName.equals(other.fullName); + boolean idMatch = this.idNum == other.idNum; + + if(nameMatch && idMatch) return true; + + return false; + } + + /** + * {@inheritDoc} + * @return {@inheritDoc} + */ + @Override + public int hashCode(){ + return this.hashNum; + } + + @Override + public String toString(){ + return getFullName(); + } + + /** + * {@inheritDoc} + * 通し番号順に順序づける。 + * @param avatar {@inheritDoc} + * @return {@inheritDoc} + */ + public int compareTo(Avatar avatar){ + if(avatar == null) return +1; + return this.idNum - avatar.idNum; + } + +} diff --git a/src/main/java/jp/sourceforge/jindolf/BalloonBorder.java b/src/main/java/jp/sourceforge/jindolf/BalloonBorder.java new file mode 100644 index 0000000..1a3a727 --- /dev/null +++ b/src/main/java/jp/sourceforge/jindolf/BalloonBorder.java @@ -0,0 +1,172 @@ +/* + * baloon border + * + * Copyright(c) 2008 olyutorskii + * $Id: BalloonBorder.java 953 2009-12-06 16:42:14Z olyutorskii $ + */ + +package jp.sourceforge.jindolf; + +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Component; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.Insets; +import java.awt.LayoutManager; +import java.awt.RenderingHints; +import javax.swing.JComponent; +import javax.swing.border.Border; + +/** + * フキダシ風Border。 + */ +public class BalloonBorder implements Border{ + + private static final int RADIUS = 5; + + /** + * 隙間が透明なフキダシ装飾を任意のコンポーネントに施す。 + * @param inner 装飾対象のコンポーネント + * @return 装飾されたコンポーネント + */ + public static JComponent decorateTransparentBorder(JComponent inner){ + JComponent result = new TransparentContainer(inner); + + Border border = new BalloonBorder(); + result.setBorder(border); + + return result; + } + + /** + * コンストラクタ。 + */ + public BalloonBorder(){ + super(); + return; + } + + /** + * {@inheritDoc} + * @param comp {@inheritDoc} + * @return {@inheritDoc} + */ + public Insets getBorderInsets(Component comp){ + Insets insets = new Insets(RADIUS, RADIUS, RADIUS, RADIUS); + return insets; + } + + /** + * {@inheritDoc} + * 必ずfalseを返す(このBorderは透明)。 + * @return {@inheritDoc} + */ + public boolean isBorderOpaque(){ + return false; + } + + /** + * {@inheritDoc} + * @param comp {@inheritDoc} + * @param g {@inheritDoc} + * @param x {@inheritDoc} + * @param y {@inheritDoc} + * @param width {@inheritDoc} + * @param height {@inheritDoc} + */ + public void paintBorder(Component comp, + Graphics g, + int x, int y, + int width, int height ){ + final int diameter = RADIUS * 2; + final int innerWidth = width - diameter; + final int innerHeight = height - diameter; + + Graphics2D g2d = (Graphics2D) g; + + Color bgColor = comp.getBackground(); + g2d.setColor(bgColor); + + Object antiAliaseHint = + g2d.getRenderingHint(RenderingHints.KEY_ANTIALIASING); + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, + RenderingHints.VALUE_ANTIALIAS_ON ); + + g2d.fillRect(x + RADIUS, y, + innerWidth, RADIUS); + g2d.fillRect(x, y + RADIUS, + RADIUS, innerHeight); + g2d.fillRect(x + RADIUS + innerWidth, y + RADIUS, + RADIUS, innerHeight); + g2d.fillRect(x + RADIUS, y + RADIUS + innerHeight, + innerWidth, RADIUS); + + int right = 90; // 90 degree right angle + + g2d.fillArc(x + innerWidth, y, + diameter, diameter, right * 0, right); + g2d.fillArc(x, y, + diameter, diameter, right * 1, right); + g2d.fillArc(x, y + innerHeight, + diameter, diameter, right * 2, right); + g2d.fillArc(x + innerWidth, y + innerHeight, + diameter, diameter, right * 3, right); + + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, antiAliaseHint); + + return; + } + + /** + * 透明コンテナ。 + * 1つの子を持ち、背景色操作を委譲する。 + * つまりこのコンテナにBorderを設定すると子の背景色が反映される。 + */ + @SuppressWarnings("serial") + private static class TransparentContainer extends JComponent{ + + private final JComponent inner; + + /** + * コンストラクタ。 + * @param inner 内部コンポーネント + */ + public TransparentContainer(JComponent inner){ + super(); + + this.inner = inner; + + setOpaque(false); + + LayoutManager layout = new BorderLayout(); + setLayout(layout); + add(this.inner, BorderLayout.CENTER); + + return; + } + + /** + * {@inheritDoc} + * 子の背景色を返す。 + * @return {@inheritDoc} + */ + @Override + public Color getBackground(){ + Color bg = this.inner.getBackground(); + return bg; + } + + /** + * {@inheritDoc} + * 背景色指定をフックし、子の背景色を指定する。 + * @param bg {@inheritDoc} + */ + @Override + public void setBackground(Color bg){ + this.inner.setBackground(bg); + return; + } + } + +} diff --git a/src/main/java/jp/sourceforge/jindolf/ClipboardAction.java b/src/main/java/jp/sourceforge/jindolf/ClipboardAction.java new file mode 100644 index 0000000..3a1b3d7 --- /dev/null +++ b/src/main/java/jp/sourceforge/jindolf/ClipboardAction.java @@ -0,0 +1,130 @@ +/* + * クリップボード操作用Action + * + * Copyright(c) 2008 olyutorskii + * $Id: ClipboardAction.java 888 2009-11-04 06:23:35Z olyutorskii $ + */ + +package jp.sourceforge.jindolf; + +import java.awt.Toolkit; +import java.awt.datatransfer.Clipboard; +import java.awt.datatransfer.StringSelection; +import java.awt.event.ActionEvent; +import javax.swing.Action; +import javax.swing.text.JTextComponent; +import javax.swing.text.TextAction; + +/** + * テキストコンポーネント-クリップボード間操作用にカスタム化したAction。 + */ +@SuppressWarnings("serial") +public class ClipboardAction extends TextAction{ + + /** アクション{@value}。 */ + public static final String ACTION_CUT = "ACTION_CUT"; + /** アクション{@value}。 */ + public static final String ACTION_COPY = "ACTION_COPY"; + /** アクション{@value}。 */ + public static final String ACTION_PASTE = "ACTION_PASTE"; + /** アクション{@value}。 */ + public static final String ACTION_SELALL = "ACTION_SELALL"; + + /** + * 文字列をクリップボードにコピーする。 + * @param data 文字列 + */ + public static void copyToClipboard(CharSequence data){ + Toolkit toolkit = Toolkit.getDefaultToolkit(); + Clipboard clipboard = toolkit.getSystemClipboard(); + StringSelection selection = new StringSelection(data.toString()); + clipboard.setContents(selection, selection); + return; + } + + /** + * カット用Actionの生成。 + * @return カット用Action + */ + public static ClipboardAction cutAction(){ + return new ClipboardAction("選択範囲をカット", ACTION_CUT); + } + + /** + * コピー用Actionの生成。 + * @return コピー用Action + */ + public static ClipboardAction copyAction(){ + return new ClipboardAction("選択範囲をコピー", ACTION_COPY); + } + + /** + * ペースト用Actionの生成。 + * @return ペースト用Action + */ + public static ClipboardAction pasteAction(){ + return new ClipboardAction("ペースト", ACTION_PASTE); + } + + /** + * 全選択用Actionの生成。 + * @return 全選択用Action + */ + public static ClipboardAction selectallAction(){ + return new ClipboardAction("すべて選択", ACTION_SELALL); + } + + /** + * コンストラクタ。 + * @param name ポップアップメニュー名 + * @param command アクションコマンド名 + */ + protected ClipboardAction(String name, String command){ + super(name); + setActionCommand(command); + return; + } + + /** + * アクションコマンド名を設定する。 + * @param actionCommand アクションコマンド名 + */ + private void setActionCommand(String actionCommand){ + putValue(Action.ACTION_COMMAND_KEY, actionCommand); + return; + } + + /** + * アクションコマンド名を取得する。 + * @return アクションコマンド名 + */ + protected String getActionCommand(){ + Object value = getValue(Action.ACTION_COMMAND_KEY); + if( ! (value instanceof String) ) return null; + + String command = (String) value; + + return command; + } + + /** + * {@inheritDoc} + * アクションの受信によってクリップボード操作を行う。 + * @param event {@inheritDoc} + */ + public void actionPerformed(ActionEvent event){ + JTextComponent textComp = getTextComponent(event); + if(textComp == null) return; + + String command = getActionCommand(); + + if (ACTION_CUT .equals(command)) textComp.cut(); + else if(ACTION_COPY .equals(command)) textComp.copy(); + else if(ACTION_PASTE .equals(command)) textComp.paste(); + else if(ACTION_SELALL.equals(command)) textComp.selectAll(); + + return; + } + + // TODO 文字列以外の物をペーストしたときに無視したい。 +} diff --git a/src/main/java/jp/sourceforge/jindolf/CmdOption.java b/src/main/java/jp/sourceforge/jindolf/CmdOption.java new file mode 100644 index 0000000..8e68dd8 --- /dev/null +++ b/src/main/java/jp/sourceforge/jindolf/CmdOption.java @@ -0,0 +1,165 @@ +/* + * command line options + * + * Copyright(c) 2009 olyutorskii + * $Id: CmdOption.java 928 2009-11-29 16:37:50Z olyutorskii $ + */ + +package jp.sourceforge.jindolf; + +import java.io.IOException; +import java.util.LinkedList; +import java.util.List; + +/** + * コマンドラインオプションの列挙。 + */ +public enum CmdOption{ + + /** ヘルプ。 */ + OPT_HELP("help", "h", "-help", "?"), + /** 版数表示。 */ + OPT_VERSION("version"), + /** UI文字制御。 */ + OPT_BOLDMETAL("boldMetal"), + /** スプラッシュ制御。 */ + OPT_NOSPLASH("nosplash"), + /** ウィンドウ位置指定。 */ + OPT_GEOMETRY("geometry"), + /** 実行環境出力。 */ + OPT_VMINFO("vminfo"), + /** コンソールログ。 */ + OPT_CONSOLELOG("consolelog"), + /** フォント指定。 */ + OPT_INITFONT("initfont"), + /** アンチエイリアス。 */ + OPT_ANTIALIAS("antialias"), + /** サブピクセル制御。 */ + OPT_FRACTIONAL("fractional"), + /** 設定格納ディレクトリ指定。 */ + OPT_CONFDIR("confdir"), + /** 設定格納ディレクトリ不使用。 */ + OPT_NOCONF("noconfdir"), + ; + + /** + * オプション名に合致するEnumを返す。 + * @param seq ハイフン付きオプション名 + * @return 合致したEnum。どれとも合致しなければnull + */ + public static CmdOption parseCmdOption(CharSequence seq){ + for(CmdOption option : values()){ + if(option.matchHyphened(seq)) return option; + } + return null; + } + + /** + * 単体で意味をなすオプションか判定する。 + * @param option オプション + * @return 単体で意味をなすならtrue + */ + public static boolean isIndepOption(CmdOption option){ + switch(option){ + case OPT_HELP: + case OPT_VERSION: + case OPT_VMINFO: + case OPT_BOLDMETAL: + case OPT_NOSPLASH: + case OPT_CONSOLELOG: + case OPT_NOCONF: + return true; + default: + break; + } + + return false; + } + + /** + * 真偽指定を一つ必要とするオプションか判定する。 + * @param option オプション + * @return 真偽指定を一つ必要とするオプションならtrue + */ + public static boolean isBooleanOption(CmdOption option){ + switch(option){ + case OPT_ANTIALIAS: + case OPT_FRACTIONAL: + return true; + default: + break; + } + + return false; + } + + /** + * ヘルプメッセージ(オプションの説明)を返す。 + * @return ヘルプメッセージ + */ + public static CharSequence getHelpText(){ + CharSequence helpText; + + try{ + helpText = Jindolf.loadResourceText("resources/help.txt"); + }catch(IOException e){ + helpText = ""; + } + + return helpText; + } + + private final List nameList = new LinkedList(); + + /** + * コンストラクタ。 + * @param names 頭のハイフンを除いたオプション名の一覧 + */ + private CmdOption(CharSequence ... names){ + if(names == null) throw new NullPointerException(); + if(names.length <= 0) throw new IllegalArgumentException(); + + for(CharSequence name : names){ + if(name == null) throw new NullPointerException(); + this.nameList.add(name.toString().intern()); + } + + return; + } + + /** + * 頭のハイフンを除いたオプション名を返す。 + * オプション名が複数指定されていた場合は最初のオプション名 + * @return オプション名 + */ + @Override + public String toString(){ + return this.nameList.get(0); + } + + /** + * 頭のハイフンが付いたオプション名を返す。 + * オプション名が複数指定されていた場合は最初のオプション名 + * @return オプション名 + */ + public String toHyphened(){ + return "-" + toString(); + } + + /** + * 任意のオプション文字列がこのオプションに合致するか判定する。 + * @param option ハイフンの付いたオプション文字列 + * @return 合致すればtrue + */ + public boolean matchHyphened(CharSequence option){ + if(option == null) return false; + + for(String name : this.nameList){ + String hyphened = "-" + name; + if(hyphened.equals(option.toString())) return true; + } + + return false; + } + +} diff --git a/src/main/java/jp/sourceforge/jindolf/CodeX0208.java b/src/main/java/jp/sourceforge/jindolf/CodeX0208.java new file mode 100644 index 0000000..698f212 --- /dev/null +++ b/src/main/java/jp/sourceforge/jindolf/CodeX0208.java @@ -0,0 +1,78 @@ +/* + * JIS X0208:1990 文字集合に関する諸々 + * + * Copyright(c) 2008 olyutorskii + * $Id: CodeX0208.java 1002 2010-03-15 12:14:20Z olyutorskii $ + */ + +package jp.sourceforge.jindolf; + +import java.io.IOException; +import java.util.Arrays; +import java.util.SortedSet; +import java.util.TreeSet; + +/** + * JIS X0208:1990 文字集合に関する諸々。 + * TODO G国がUTF-8化した今、このクラスは不要? + */ +public final class CodeX0208{ + + private static final String RESOURCE_INVALIDCHAR = + "resources/invalidX0208.txt"; + private static final char[] INVALID_CHAR_ARRAY = createInvalidCharArray(); + + /** + * ソートされた、禁止文字配列を生成する。 + * @return 禁止文字配列。 + */ + private static char[] createInvalidCharArray(){ + CharSequence source; + try{ + source = Jindolf.loadResourceText(RESOURCE_INVALIDCHAR); + }catch(IOException e){ + assert false; + return null; + } + + SortedSet charSet = new TreeSet(); + int sourceLength = source.length(); + for(int pos = 0; pos < sourceLength; pos++){ + char ch = source.charAt(pos); + if(Character.isWhitespace(ch)) continue; + charSet.add(ch); + } + + char[] result = new char[charSet.size()]; + int pos = 0; + for(char ch : charSet){ + result[pos++] = ch; + } + + Arrays.sort(result); + + return result; + } + + /** + * 禁止文字か否か判定する。 + * @param ch 判定対象文字 + * @return 禁止ならfalse + */ + public static boolean isValid(char ch){ + int index = Arrays.binarySearch(INVALID_CHAR_ARRAY, ch); + if(index < 0) return true; + return false; + } + + /** + * ダミーコンストラクタ。 + */ + private CodeX0208(){ + assert false; + throw new AssertionError(); + } + + // TODO アラビア語やハングルやも弾きたい。 + // TODO JISエンコーダと区点チェックに作り直すか? +} diff --git a/src/main/java/jp/sourceforge/jindolf/ConfigFile.java b/src/main/java/jp/sourceforge/jindolf/ConfigFile.java new file mode 100644 index 0000000..2367fc1 --- /dev/null +++ b/src/main/java/jp/sourceforge/jindolf/ConfigFile.java @@ -0,0 +1,849 @@ +/* + * configuration file & directory + * + * Copyright(c) 2009 olyutorskii + * $Id: ConfigFile.java 928 2009-11-29 16:37:50Z olyutorskii $ + */ + +package jp.sourceforge.jindolf; + +import java.awt.Component; +import java.awt.HeadlessException; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.PrintWriter; +import java.io.Reader; +import java.io.Writer; +import java.nio.charset.Charset; +import javax.swing.ButtonGroup; +import javax.swing.JButton; +import javax.swing.JDialog; +import javax.swing.JOptionPane; +import javax.swing.JRadioButton; +import jp.sourceforge.jindolf.json.JsParseException; +import jp.sourceforge.jindolf.json.JsValue; +import jp.sourceforge.jindolf.json.Json; + +/** + * Jindolf設定格納ディレクトリに関するあれこれ。 + */ +public final class ConfigFile{ + + private static final String TITLE_BUILDCONF = + Jindolf.TITLE + "設定格納ディレクトリの設定"; + + private static final String JINCONF = "Jindolf"; + private static final String JINCONF_DOT = ".jindolf"; + private static final String FILE_README = "README.txt"; + private static final Charset CHARSET_README = Charset.forName("UTF-8"); + private static final Charset CHARSET_JSON = Charset.forName("UTF-8"); + + private static final String MSG_POST = + "
    " + + "
  • " + CmdOption.OPT_CONFDIR.toHyphened() + "" + + " ã‚ªãƒ—ション指定により、
    " + + "任意の設定格納ディレクトリを指定することができます。
    " + + "
  • " + CmdOption.OPT_NOCONF.toHyphened() + "" + + " ã‚ªãƒ—ション指定により、
    " + + "設定格納ディレクトリを使わずに起動することができます。
    " + + "
"; + + + /** + * 設定格納ディレクトリのセットアップ。 + * @return 設定格納ディレクトリ + */ + public static File setupConfigDirectory(){ + AppSetting setting = Jindolf.getAppSetting(); + File configPath; + + if( ! setting.useConfigPath() ){ + configPath = null; + }else{ + String optName; + if(setting.getConfigPath() != null){ + configPath = setting.getConfigPath(); + optName = CmdOption.OPT_CONFDIR.toHyphened(); + }else{ + configPath = ConfigFile.getImplicitConfigDirectory(); + optName = null; + } + if( ! configPath.exists() ){ + configPath = + ConfigFile.buildConfigDirectory(configPath, optName); + } + ConfigFile.checkAccessibility(configPath); + } + + setting.setConfigPath(configPath); + + return configPath; + } + + /** + * ロックファイルのセットアップ。 + * @return ロックオブジェクト + */ + public static InterVMLock setupLockFile(){ + AppSetting setting = Jindolf.getAppSetting(); + + File configPath = setting.getConfigPath(); + if(configPath == null) return null; + + File lockFile = new File(configPath, "lock"); + InterVMLock lock = new InterVMLock(lockFile); + + lock.tryLock(); + + if( ! lock.isFileOwner() ){ + confirmLockError(lock); + if( ! lock.isFileOwner() ){ + setting.setConfigPath(null); + setting.setUseConfigPath(false); + } + } + + return lock; + } + + /** + * 暗黙的な設定格納ディレクトリを返す。 + * 起動元JARファイルと同じディレクトリに、 + * アクセス可能なディレクトリ"Jindolf"が + * すでに存在していればそれを返す。 + * 起動元JARファイルおよび"Jindolf"が発見できなければ、 + * MacOSX環境の場合"~/Library/Application Support/Jindolf/"を返す。 + * Windows環境の場合"%USERPROFILE%\Jindolf\"を返す。 + * それ以外の環境(Linux,etc?)の場合"~/.jindolf/"を返す。 + * 返すディレクトリが存在しているか否か、 + * アクセス可能か否かは呼び出し元で判断せよ。 + * @return 設定格納ディレクトリ + */ + public static File getImplicitConfigDirectory(){ + File result; + + File jarParent = FileUtils.getJarDirectory(Jindolf.class); + if(jarParent != null && FileUtils.isAccessibleDirectory(jarParent)){ + result = new File(jarParent, JINCONF); + if(FileUtils.isAccessibleDirectory(result)){ + return result; + } + } + + File appset = FileUtils.getAppSetDir(); + if(appset == null) return null; + + if(FileUtils.isMacOSXFs() || FileUtils.isWindowsOSFs()){ + result = new File(appset, JINCONF); + }else{ + result = new File(appset, JINCONF_DOT); + } + + return result; + } + + /** + * まだ存在しない設定格納ディレクトリを新規に作成する。 + * エラーがあればダイアログ提示とともにVM終了する。 + * @param confPath 設定格納ディレクトリ + * @param optName 設定を指定したオプション名。 + * 暗黙的に指示されたものならnullを渡すべし。 + * @return 新規に作成した設定格納ディレクトリ + * @throws IllegalArgumentException すでにそのディレクトリは存在する。 + */ + public static File buildConfigDirectory(File confPath, + String optName) + throws IllegalArgumentException{ + if(confPath.exists()) throw new IllegalArgumentException(); + + File absPath = FileUtils.supplyFullPath(confPath); + + String preErrMessage = + "設定格納ディレクトリ
" + + getCenteredFileName(absPath) + + "の作成に失敗しました。"; + if(optName != null){ + preErrMessage = + "" + optName + " ã‚ªãƒ—ション" + + "で指定された、
" + + preErrMessage; + } + + File existsAncestor = FileUtils.findExistsAncestor(absPath); + if(existsAncestor == null){ + abortNoRoot(absPath, preErrMessage); + }else if( ! existsAncestor.canWrite() ){ + abortCantWriteAncestor(existsAncestor, preErrMessage); + } + + String prompt = + "設定ファイル格納ディレクトリ
" + + getCenteredFileName(absPath) + + "を作成します。"; + boolean confirmed = confirmBuildConfigDir(existsAncestor, prompt); + if( ! confirmed ){ + abortQuitBuildConfigDir(); + } + + boolean success; + try{ + success = absPath.mkdirs(); + }catch(SecurityException e){ + success = false; + } + + if( ! success || ! absPath.exists() ){ + abortCantBuildConfigDir(absPath); + } + + FileUtils.setOwnerOnlyAccess(absPath); + + checkAccessibility(absPath); + + touchReadme(absPath); + + return absPath; + } + + /** + * 設定ディレクトリ操作の + * 共通エラーメッセージ確認ダイアログを表示する。 + * 閉じるまで待つ。 + * @param seq メッセージ + */ + private static void showErrorMessage(CharSequence seq){ + JOptionPane pane = + new JOptionPane(seq.toString(), + JOptionPane.ERROR_MESSAGE); + showDialog(pane); + return; + } + + /** + * 設定ディレクトリ操作の + * 共通エラーメッセージ確認ダイアログを表示する。 + * 閉じるまで待つ。 + * @param seq メッセージ + */ + private static void showWarnMessage(CharSequence seq){ + JOptionPane pane = + new JOptionPane(seq.toString(), + JOptionPane.WARNING_MESSAGE); + showDialog(pane); + return; + } + + /** + * 設定ディレクトリ操作の + * 情報提示メッセージ確認ダイアログを表示する。 + * 閉じるまで待つ。 + * @param seq メッセージ + */ + private static void showInfoMessage(CharSequence seq){ + JOptionPane pane = + new JOptionPane(seq.toString(), + JOptionPane.INFORMATION_MESSAGE); + showDialog(pane); + return; + } + + /** + * ダイアログを表示し、閉じられるまで待つ。 + * @param pane ダイアログの元となるペイン + */ + private static void showDialog(JOptionPane pane){ + JDialog dialog = pane.createDialog(null, TITLE_BUILDCONF); + dialog.setResizable(true); + dialog.pack(); + + dialog.setVisible(true); + dialog.dispose(); + + return; + } + + /** + * 設定ディレクトリのルートファイルシステムもしくはドライブレターに + * アクセスできないエラーをダイアログに提示し、VM終了する。 + * @param path 設定ディレクトリ + * @param preMessage メッセージ前半 + */ + private static void abortNoRoot(File path, String preMessage){ + File root = FileUtils.findRootFile(path); + showErrorMessage( + "" + + preMessage + "
" + + getCenteredFileName(root) + + "を用意する方法が不明です。
" + + "起動を中止します。
" + + MSG_POST + + "" ); + Jindolf.exit(1); + return; + } + + /** + * 設定ディレクトリの祖先に書き込めないエラーをダイアログで提示し、 + * VM終了する。 + * @param existsAncestor 存在するもっとも近い祖先 + * @param preMessage メッセージ前半 + */ + private static void abortCantWriteAncestor(File existsAncestor, + String preMessage ){ + showErrorMessage( + "" + + preMessage + "
" + + getCenteredFileName(existsAncestor) + + "への書き込みができないため、" + + "処理の続行は不可能です。
" + + "起動を中止します。
" + + MSG_POST + + "" ); + Jindolf.exit(1); + return; + } + + /** + * 設定ディレクトリを新規に生成してよいかダイアログで問い合わせる。 + * @param existsAncestor 存在するもっとも近い祖先 + * @param preMessage メッセージ前半 + * @return 生成してよいと指示があればtrue + */ + private static boolean confirmBuildConfigDir(File existsAncestor, + String preMessage){ + String message = + "" + + preMessage + "
" + + "このディレクトリを今から
" + + getCenteredFileName(existsAncestor) + + "に作成して構いませんか?
" + + "このディレクトリ名は、後からいつでもヘルプウィンドウで
" + + "確認することができます。" + + ""; + + JOptionPane pane = + new JOptionPane(message, + JOptionPane.QUESTION_MESSAGE, + JOptionPane.YES_NO_OPTION); + + showDialog(pane); + + Object result = pane.getValue(); + if(result == null) return false; + else if( ! (result instanceof Integer) ) return false; + + int ival = (Integer) result; + if(ival == JOptionPane.YES_OPTION) return true; + + return false; + } + + /** + * 設定ディレクトリ生成をやめた操作への警告をダイアログで提示し、 + * VM終了する。 + */ + private static void abortQuitBuildConfigDir(){ + showWarnMessage( + "" + + "設定ディレクトリの作成をせずに起動を中止します。
" + + MSG_POST + + "" ); + Jindolf.exit(1); + return; + } + + /** + * 設定ディレクトリが生成できないエラーをダイアログで提示し、 + * VM終了する。 + * @param path 生成できなかったディレクトリ + */ + private static void abortCantBuildConfigDir(File path){ + showErrorMessage( + "" + + "設定ディレクトリ
" + + getCenteredFileName(path) + + "の作成に失敗しました。" + + "起動を中止します。
" + + MSG_POST + + "" ); + Jindolf.exit(1); + return; + } + + /** + * 設定ディレクトリへアクセスできないエラーをダイアログで提示し、 + * VM終了する。 + * @param path アクセスできないディレクトリ + */ + private static void abortCantAccessConfigDir(File path){ + showErrorMessage( + "" + + "設定ディレクトリ
" + + getCenteredFileName(path) + + "へのアクセスができません。" + + "起動を中止します。
" + + "このディレクトリへのアクセス権を調整し" + + "読み書きできるようにしてください。
" + + MSG_POST + + "" ); + Jindolf.exit(1); + return; + } + + /** + * ファイルに書き込めないエラーをダイアログで提示し、VM終了する。 + * @param file 書き込めなかったファイル + */ + private static void abortCantWrite(File file){ + showErrorMessage( + "" + + "ファイル
" + + getCenteredFileName(file) + + "への書き込みができません。" + + "起動を中止します。
" + + "" ); + Jindolf.exit(1); + return; + } + + /** + * 指定されたディレクトリにREADMEファイルを生成する。 + * 生成できなければダイアログ表示とともにVM終了する。 + * @param path READMEの格納ディレクトリ + */ + private static void touchReadme(File path){ + File file = new File(path, FILE_README); + + try{ + file.createNewFile(); + }catch(IOException e){ + abortCantAccessConfigDir(path); + } + + PrintWriter writer = null; + try{ + OutputStream ostream = new FileOutputStream(file); + Writer owriter = new OutputStreamWriter(ostream, CHARSET_README); + writer = new PrintWriter(owriter); + writer.println(CHARSET_README.name() + " Japanese"); + writer.println( + "このディレクトリは、" + + "Jindolfの各種設定が格納されるディレクトリです。"); + writer.println( + "Jindolfの詳細は " + + "http://jindolf.sourceforge.jp/" + + " を参照してください。"); + writer.println( + "このディレクトリを" + + "「" + JINCONF + "」" + + "の名前で起動元JARファイルと" + + "同じ位置に"); + writer.println( + "コピーすれば、そちらの設定が優先して使われます。"); + writer.println( + "「lock」の名前を持つファイルはロックファイルです。"); + }catch(IOException e){ + abortCantWrite(file); + }catch(SecurityException e){ + abortCantWrite(file); + }finally{ + if(writer != null){ + writer.close(); + } + } + + return; + } + + /** + * 設定ディレクトリがアクセス可能でなければ + * エラーダイアログを出してVM終了する。 + * @param confDir 設定ディレクトリ + */ + public static void checkAccessibility(File confDir){ + if( ! FileUtils.isAccessibleDirectory(confDir) ){ + abortCantAccessConfigDir(confDir); + } + + return; + } + + /** + * センタリングされたファイル名表示のHTML表記を出力する。 + * @param path ファイル + * @return HTML表記 + */ + public static String getCenteredFileName(File path){ + return "
[ " + + FileUtils.getHtmledFileName(path) + + " ]
" + + "
"; + } + + /** + * 隠れコンストラクタ。 + */ + private ConfigFile(){ + super(); + return; + } + + /** + * ロックエラーダイアログの表示。 + * 呼び出しから戻ってもまだロックオブジェクトが + * ロックファイルのオーナーでない場合、 + * 今後設定ディレクトリは一切使わずに起動を続行するものとする。 + * ロックファイルの強制解除に失敗した場合はVM終了する。 + * @param lock エラーを起こしたロック + */ + public static void confirmLockError(InterVMLock lock){ + LockErrorPane pane = new LockErrorPane(lock); + JDialog dialog = pane.createDialog(null, TITLE_BUILDCONF); + dialog.setResizable(true); + dialog.pack(); + + for(;;){ + dialog.setVisible(true); + dialog.dispose(); + + if(pane.isAborted() || pane.getValue() == null){ + Jindolf.exit(1); + break; + }else if(pane.isRadioRetry()){ + lock.tryLock(); + if(lock.isFileOwner()) break; + }else if(pane.isRadioContinue()){ + showInfoMessage( + "" + + "設定ディレクトリを使わずに起動を続行します。
" + + "今回、各種設定の読み込み・保存はできません。
" + + "" + + CmdOption.OPT_NOCONF.toHyphened() + + " オプション" + + "を使うとこの警告は出なくなります。" + + ""); + break; + }else if(pane.isRadioForce()){ + lock.forceRemove(); + if(lock.isExistsFile()){ + showErrorMessage( + "" + + "ロックファイルの強制解除に失敗しました。
" + + "他に動いているJindolf" + + "が見つからないのであれば、
" + + "なんとかしてロックファイル
" + + getCenteredFileName(lock.getLockFile()) + + "を削除してください。
" + + "起動を中止します。" + + ""); + Jindolf.exit(1); + break; + } + lock.tryLock(); + if(lock.isFileOwner()) break; + showErrorMessage( + "" + + "ロックファイル
" + + getCenteredFileName(lock.getLockFile()) + + "を確保することができません。
" + + "起動を中止します。" + + ""); + Jindolf.exit(1); + break; + } + } + + return; + } + + /** + * 設定ディレクトリ上のJSONファイルを読み込む。 + * @param file JSONファイルの相対パス + * @return JSON objectまたはarray。 + * 設定ディレクトリを使わない設定、 + * もしくはJSONファイルが存在しない、 + * もしくは入力エラーがあればnull + */ + public static JsValue loadJson(File file){ + AppSetting setting = Jindolf.getAppSetting(); + if( ! setting.useConfigPath() ) return null; + + File absFile; + if(file.isAbsolute()){ + absFile = file; + }else{ + File configPath = setting.getConfigPath(); + if(configPath == null) return null; + absFile = new File(configPath, file.getPath()); + if( ! absFile.exists() ) return null; + if( ! absFile.isAbsolute() ) return null; + } + + InputStream istream; + try{ + istream = new FileInputStream(absFile); + }catch(FileNotFoundException e){ + assert false; + return null; + } + istream = new BufferedInputStream(istream); + + Reader reader = new InputStreamReader(istream, CHARSET_JSON); + + JsValue value; + try{ + value = Json.parseValue(reader); + }catch(IOException e){ + Jindolf.logger().fatal( + "JSONファイル[" + + absFile.getPath() + + "]の読み込み時に支障がありました。", e); + return null; + }catch(JsParseException e){ + Jindolf.logger().fatal( + "JSONファイル[" + + absFile.getPath() + + "]の内容に不備があります。", e); + return null; + }finally{ + try{ + reader.close(); + }catch(IOException e){ + Jindolf.logger().fatal( + "JSONファイル[" + + absFile.getPath() + + "]を閉じることができません。", e); + return null; + } + } + + return value; + } + + /** + * 設定ディレクトリ上のJSONファイルに書き込む。 + * @param file JSONファイルの相対パス + * @param value JSON objectまたはarray + * @return 正しくセーブが行われればtrue。 + * 何らかの理由でセーブが完了できなければfalse + */ + public static boolean saveJson(File file, JsValue value){ + AppSetting setting = Jindolf.getAppSetting(); + if( ! setting.useConfigPath() ) return false; + File configPath = setting.getConfigPath(); + if(configPath == null) return false; + + // TODO テンポラリファイルを用いたより安全なファイル更新 + File absFile = new File(configPath, file.getPath()); + absFile.delete(); + try{ + if(absFile.createNewFile() != true) return false; + }catch(IOException e){ + Jindolf.logger().fatal( + "JSONファイル[" + + absFile.getPath() + + "]の新規生成ができません。", e); + return false; + } + + OutputStream ostream; + try{ + ostream = new FileOutputStream(absFile); + }catch(FileNotFoundException e){ + assert false; + return false; + } + ostream = new BufferedOutputStream(ostream); + Writer writer = new OutputStreamWriter(ostream, CHARSET_JSON); + + try{ + Json.writeJsonTop(writer, value); + }catch(IOException e){ + Jindolf.logger().fatal( + "JSONファイル[" + + absFile.getPath() + + "]の書き込み時に支障がありました。", e); + return false; + }finally{ + try{ + writer.close(); + }catch(IOException e){ + Jindolf.logger().fatal( + "JSONファイル[" + + absFile.getPath() + + "]を閉じることができません。", e); + return false; + } + } + + return true; + } + + /** + * ロックエラー用ダイアログ。 + *
    + *
  • 強制解除 + *
  • リトライ + *
  • 設定ディレクトリを無視 + *
  • 起動中止 + *
+ * の選択を利用者に求める。 + */ + @SuppressWarnings("serial") + private static class LockErrorPane + extends JOptionPane + implements ActionListener{ + + private final InterVMLock lock; + + private final JRadioButton continueButton = + new JRadioButton("設定ディレクトリを使わずに起動を続行"); + private final JRadioButton retryButton = + new JRadioButton("再度ロック取得を試す"); + private final JRadioButton forceButton = + new JRadioButton( + "" + + "ロックを強制解除
" + + " (※他のJindolfと設定ファイル書き込みが衝突するかも…)" + + ""); + + private final JButton okButton = new JButton("OK"); + private final JButton abortButton = new JButton("起動中止"); + + private boolean aborted = false; + + /** + * コンストラクタ。 + * @param lock 失敗したロック + */ + public LockErrorPane(InterVMLock lock){ + super(); + + this.lock = lock; + + String htmlMessage = + "" + + "設定ディレクトリのロックファイル
" + + getCenteredFileName(this.lock.getLockFile()) + + "のロックに失敗しました。
" + + "考えられる原因としては、
" + + "
    " + + "
  • 前回起動したJindolfの終了が正しく行われなかった" + + "
  • 今どこかで他のJindolfが動いている" + + "
" + + "などが考えられます。
" + + "
" + + ""; + + ButtonGroup bgrp = new ButtonGroup(); + bgrp.add(this.continueButton); + bgrp.add(this.retryButton); + bgrp.add(this.forceButton); + this.continueButton.setSelected(true); + + Object[] msg = { + htmlMessage, + this.continueButton, + this.retryButton, + this.forceButton, + }; + setMessage(msg); + + Object[] opts = { + this.okButton, + this.abortButton, + }; + setOptions(opts); + + setMessageType(JOptionPane.ERROR_MESSAGE); + + this.okButton .addActionListener(this); + this.abortButton.addActionListener(this); + + return; + } + + /** + * 「設定ディレクトリを無視して続行」が選択されたか判定する。 + * @return 「無視して続行」が選択されていればtrue + */ + public boolean isRadioContinue(){ + return this.continueButton.isSelected(); + } + + /** + * 「リトライ」が選択されたか判定する。 + * @return 「リトライ」が選択されていればtrue + */ + public boolean isRadioRetry(){ + return this.retryButton.isSelected(); + } + + /** + * 「強制解除」が選択されたか判定する。 + * @return 「強制解除」が選択されていればtrue + */ + public boolean isRadioForce(){ + return this.forceButton.isSelected(); + } + + /** + * 「起動中止」が選択されたか判定する。 + * @return 「起動中止」が押されていたならtrue + */ + public boolean isAborted(){ + return this.aborted; + } + + /** + * {@inheritDoc} + * @param parentComponent {@inheritDoc} + * @param title {@inheritDoc} + * @return {@inheritDoc} + * @throws HeadlessException {@inheritDoc} + */ + @Override + public JDialog createDialog(Component parentComponent, + String title) + throws HeadlessException{ + final JDialog dialog = + super.createDialog(parentComponent, title); + + ActionListener listener = new ActionListener(){ + public void actionPerformed(ActionEvent event){ + dialog.setVisible(false); + return; + } + }; + + this.okButton .addActionListener(listener); + this.abortButton.addActionListener(listener); + + return dialog; + } + + /** + * ボタン押下を受信する。 + * @param event イベント + */ + public void actionPerformed(ActionEvent event){ + Object source = event.getSource(); + if(source == this.okButton) this.aborted = false; + else this.aborted = true; + return; + } + + } + +} diff --git a/src/main/java/jp/sourceforge/jindolf/Controller.java b/src/main/java/jp/sourceforge/jindolf/Controller.java new file mode 100644 index 0000000..9be385b --- /dev/null +++ b/src/main/java/jp/sourceforge/jindolf/Controller.java @@ -0,0 +1,1634 @@ +/* + * MVC controller + * + * Copyright(c) 2008 olyutorskii + * $Id: Controller.java 999 2010-03-15 11:59:28Z olyutorskii $ + */ + +package jp.sourceforge.jindolf; + +import java.awt.BorderLayout; +import java.awt.Component; +import java.awt.Container; +import java.awt.Cursor; +import java.awt.EventQueue; +import java.awt.Frame; +import java.awt.LayoutManager; +import java.awt.Window; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.awt.event.KeyAdapter; +import java.awt.event.MouseAdapter; +import java.awt.event.WindowAdapter; +import java.awt.event.WindowEvent; +import java.io.File; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.URL; +import java.net.URLEncoder; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.logging.Handler; +import java.util.logging.Logger; +import java.util.regex.Pattern; +import javax.swing.JButton; +import javax.swing.JComponent; +import javax.swing.JDialog; +import javax.swing.JFrame; +import javax.swing.JOptionPane; +import javax.swing.JToolBar; +import javax.swing.JTree; +import javax.swing.LookAndFeel; +import javax.swing.SwingUtilities; +import javax.swing.UIManager; +import javax.swing.UnsupportedLookAndFeelException; +import javax.swing.WindowConstants; +import javax.swing.event.ChangeEvent; +import javax.swing.event.ChangeListener; +import javax.swing.event.TreeExpansionEvent; +import javax.swing.event.TreeSelectionEvent; +import javax.swing.event.TreeSelectionListener; +import javax.swing.event.TreeWillExpandListener; +import javax.swing.tree.TreePath; +import jp.sourceforge.jindolf.corelib.LandDef; +import jp.sourceforge.jindolf.corelib.VillageState; + +/** + * いわゆるMVCでいうとこのコントローラ。 + */ +public class Controller + implements ActionListener, + TreeWillExpandListener, + TreeSelectionListener, + ChangeListener, + AnchorHitListener { + + private final ActionManager actionManager; + private final TopView topView; + private final LandsModel model; + + private final FilterPanel filterFrame; + private final LogFrame showlogFrame; + private final OptionPanel optionPanel; + private final FindPanel findPanel; + private final TalkPreview talkPreview; + private JFrame helpFrame; + private AccountPanel accountFrame; + private DaySummary daySummaryPanel; + private VillageDigest digestPanel; + private final Map windowMap = + new HashMap(); + + private volatile boolean isBusyNow; + + private JFrame topFrame = null; + + /** + * コントローラの生成。 + * @param actionManager アクション管理 + * @param topView 最上位ビュー + * @param model 最上位データモデル + */ + public Controller(ActionManager actionManager, + TopView topView, + LandsModel model){ + super(); + + this.actionManager = actionManager; + this.topView = topView; + this.model = model; + + JToolBar toolbar = this.actionManager.getBrowseToolBar(); + this.topView.setBrowseToolBar(toolbar); + + this.actionManager.addActionListener(this); + + JTree treeView = this.topView.getTreeView(); + treeView.setModel(this.model); + treeView.addTreeWillExpandListener(this); + treeView.addTreeSelectionListener(this); + + this.topView.getTabBrowser().addChangeListener(this); + this.topView.getTabBrowser().addActionListener(this); + this.topView.getTabBrowser().addAnchorHitListener(this); + + JButton reloadVillageListButton = this.topView + .getLandsTree() + .getReloadVillageListButton(); + reloadVillageListButton.addActionListener(this); + reloadVillageListButton.setEnabled(false); + + this.filterFrame = new FilterPanel(this.topFrame); + this.filterFrame.addChangeListener(this); + this.filterFrame.pack(); + this.filterFrame.setVisible(false); + + this.showlogFrame = new LogFrame(this.topFrame); + this.showlogFrame.pack(); + this.showlogFrame.setSize(600, 500); + this.showlogFrame.setLocationByPlatform(true); + this.showlogFrame.setVisible(false); + if(Jindolf.hasLoggingPermission()){ + Handler newHandler = this.showlogFrame.getHandler(); + Logger jre14Logger = Jindolf.logger().getJre14Logger(); + jre14Logger.addHandler(newHandler); + Handler[] handlers = jre14Logger.getHandlers(); + for(Handler handler : handlers){ + if( ! (handler instanceof PileHandler) ) continue; + PileHandler pile = (PileHandler) handler; + pile.delegate(newHandler); + pile.close(); + } + } + + this.talkPreview = new TalkPreview(); + this.talkPreview.pack(); + this.talkPreview.setSize(700, 500); + this.talkPreview.setVisible(false); + this.talkPreview.loadDraft(); + + this.optionPanel = new OptionPanel(this.topFrame); + this.optionPanel.pack(); + this.optionPanel.setSize(450, 500); + this.optionPanel.setVisible(false); + + this.findPanel = new FindPanel(this.topFrame); + this.findPanel.pack(); + this.findPanel.setVisible(false); + this.findPanel.loadHistory(); + + this.windowMap.put(this.filterFrame, true); + this.windowMap.put(this.showlogFrame, false); + this.windowMap.put(this.talkPreview, false); + this.windowMap.put(this.optionPanel, false); + this.windowMap.put(this.findPanel, true); + + AppSetting setting = Jindolf.getAppSetting(); + + FontInfo fontInfo = setting.getFontInfo(); + this.topView.getTabBrowser().setFontInfo(fontInfo); + this.talkPreview.setFontInfo(fontInfo); + this.optionPanel.getFontChooser().setFontInfo(fontInfo); + + ProxyInfo proxyInfo = setting.getProxyInfo(); + this.optionPanel.getProxyChooser().setProxyInfo(proxyInfo); + + DialogPref pref = setting.getDialogPref(); + this.topView.getTabBrowser().setDialogPref(pref); + this.optionPanel.getDialogPrefPanel().setDialogPref(pref); + + return; + } + + /** + * トップフレームを生成する。 + * @return トップフレーム + */ + @SuppressWarnings("serial") + public JFrame createTopFrame(){ + this.topFrame = new JFrame(); + + Container content = this.topFrame.getContentPane(); + LayoutManager layout = new BorderLayout(); + content.setLayout(layout); + content.add(this.topView, BorderLayout.CENTER); + + Component glassPane = new JComponent() {}; + glassPane.addMouseListener(new MouseAdapter() {}); + glassPane.addKeyListener(new KeyAdapter() {}); + this.topFrame.setGlassPane(glassPane); + + this.topFrame.setJMenuBar(this.actionManager.getMenuBar()); + setFrameTitle(null); + + this.windowMap.put(this.topFrame, false); + + this.topFrame.setDefaultCloseOperation( + WindowConstants.DISPOSE_ON_CLOSE); + this.topFrame.addWindowListener(new WindowAdapter(){ + @Override + public void windowClosed(WindowEvent event){ + shutdown(); + } + }); + + return this.topFrame; + } + + /** + * About画面を表示する。 + */ + private void actionAbout(){ + String message = + Jindolf.TITLE + + " Version " + Jindolf.VERSION + "\n" + + Jindolf.COPYRIGHT + "\n" + + "ライセンス: " + Jindolf.LICENSE + "\n" + + "連絡先: " + Jindolf.CONTACT; + + if(Jindolf.COMMENT.length() > 0){ + message += "\n" + Jindolf.COMMENT; + } + + JOptionPane pane = new JOptionPane(message, + JOptionPane.INFORMATION_MESSAGE, + JOptionPane.DEFAULT_OPTION, + GUIUtils.getLogoIcon()); + + JDialog dialog = pane.createDialog(this.topFrame, + Jindolf.TITLE + "について"); + + dialog.pack(); + dialog.setVisible(true); + dialog.dispose(); + + return; + } + + /** + * アプリ終了。 + */ + private void actionExit(){ + shutdown(); + return; + } + + /** + * Help画面を表示する。 + */ + private void actionHelp(){ + if(this.helpFrame != null){ // show Toggle + toggleWindow(this.helpFrame); + return; + } + + this.helpFrame = new HelpFrame(); + this.helpFrame.pack(); + this.helpFrame.setSize(450, 450); + + this.windowMap.put(this.helpFrame, false); + + this.helpFrame.setVisible(true); + + return; + } + + /** + * 村をWebブラウザで表示する。 + */ + private void actionShowWebVillage(){ + TabBrowser browser = this.topView.getTabBrowser(); + Village village = browser.getVillage(); + if(village == null) return; + + Land land = village.getParentLand(); + ServerAccess server = land.getServerAccess(); + + URL url = server.getVillageURL(village); + + String urlText = url.toString(); + if(village.getState() != VillageState.GAMEOVER){ + urlText += "#bottom"; + } + + WebIPCDialog.showDialog(this.topFrame, urlText); + + return; + } + + /** + * 村に対応するまとめサイトをWebブラウザで表示する。 + */ + private void actionShowWebWiki(){ + TabBrowser browser = this.topView.getTabBrowser(); + Village village = browser.getVillage(); + if(village == null) return; + + String villageName; + LandDef landDef = village.getParentLand().getLandDef(); + if(landDef.getLandId().equals("wolfg")){ + String vnum = "000" + village.getVillageID(); + vnum = vnum.substring(vnum.length() - 3); + villageName = landDef.getLandPrefix() + vnum; + }else{ + villageName = village.getVillageName(); + } + + StringBuilder url = + new StringBuilder() + .append("http://wolfbbs.jp/") + .append(villageName) + .append("%C2%BC.html"); + + WebIPCDialog.showDialog(this.topFrame, url.toString()); + + return; + } + + /** + * 村に対応するキャスト紹介表ジェネレーターをWebブラウザで表示する。 + */ + private void actionShowWebCast(){ + TabBrowser browser = this.topView.getTabBrowser(); + Village village = browser.getVillage(); + if(village == null) return; + + Land land = village.getParentLand(); + ServerAccess server = land.getServerAccess(); + + URL villageUrl = server.getVillageURL(village); + + StringBuilder url = new StringBuilder("http://hon5.com/jinro/"); + + try{ + url.append("?u=") + .append(URLEncoder.encode(villageUrl.toString(), "UTF-8")); + }catch(UnsupportedEncodingException e){ + return; + } + + url.append("&s=1"); + + WebIPCDialog.showDialog(this.topFrame, url.toString()); + + return; + } + + /** + * 日(Period)をWebブラウザで表示する。 + */ + private void actionShowWebDay(){ + PeriodView periodView = currentPeriodView(); + if(periodView == null) return; + + Period period = periodView.getPeriod(); + if(period == null) return; + + TabBrowser browser = this.topView.getTabBrowser(); + Village village = browser.getVillage(); + if(village == null) return; + + Land land = village.getParentLand(); + ServerAccess server = land.getServerAccess(); + + URL url = server.getPeriodURL(period); + + String urlText = url.toString(); + if(period.isHot()) urlText += "#bottom"; + + WebIPCDialog.showDialog(this.topFrame, urlText); + + return; + } + + /** + * 個別の発言をWebブラウザで表示する。 + */ + private void actionShowWebTalk(){ + TabBrowser browser = this.topView.getTabBrowser(); + Village village = browser.getVillage(); + if(village == null) return; + + PeriodView periodView = currentPeriodView(); + if(periodView == null) return; + + Discussion discussion = periodView.getDiscussion(); + Talk talk = discussion.getPopupedTalk(); + if(talk == null) return; + + Period period = periodView.getPeriod(); + if(period == null) return; + + Land land = village.getParentLand(); + ServerAccess server = land.getServerAccess(); + + URL url = server.getPeriodURL(period); + + String urlText = url.toString(); + urlText += "#" + talk.getMessageID(); + WebIPCDialog.showDialog(this.topFrame, urlText); + + return; + } + + /** + * ポータルサイトをWebブラウザで表示する。 + */ + private void actionShowPortal(){ + WebIPCDialog.showDialog(this.topFrame, Jindolf.CONTACT); + return; + } + + /** + * L&Fの変更を行う。 + */ + // TODO Nimbus対応 + private void actionChangeLaF(){ + String className = this.actionManager.getSelectedLookAndFeel(); + + LookAndFeel lnf; + try{ + Class lnfClass = Class.forName(className); + lnf = (LookAndFeel)( lnfClass.newInstance() ); + }catch(Exception e){ + String message = "このLook&Feel[" + + className + + "]を読み込む事ができません。"; + Jindolf.logger().warn(message, e); + JOptionPane.showMessageDialog( + this.topFrame, + message, + "Look&Feel - " + Jindolf.TITLE, + JOptionPane.WARNING_MESSAGE ); + return; + } + + try{ + UIManager.setLookAndFeel(lnf); + }catch(UnsupportedLookAndFeelException e){ + String message = "このLook&Feel[" + + lnf.getName() + + "]はサポートされていません。"; + Jindolf.logger().warn(message, e); + JOptionPane.showMessageDialog( + this.topFrame, + message, + "Look&Feel - " + Jindolf.TITLE, + JOptionPane.WARNING_MESSAGE ); + return; + } + + Jindolf.logger().info( + "Look&Feelが[" + +lnf.getName() + +"]に変更されました。"); + + final Runnable updateUITask = new Runnable(){ + public void run(){ + Set windows = Controller.this.windowMap.keySet(); + for(Window window : windows){ + SwingUtilities.updateComponentTreeUI(window); + window.validate(); + boolean needPack = Controller.this.windowMap.get(window); + if(needPack){ + window.pack(); + } + } + + return; + } + }; + + Executor executor = Executors.newCachedThreadPool(); + executor.execute(new Runnable(){ + public void run(){ + setBusy(true); + updateStatusBar("Look&Feelを更新中…"); + try{ + SwingUtilities.invokeAndWait(updateUITask); + }catch(Exception e){ + Jindolf.logger().warn( + "Look&Feelの更新に失敗しました。", e); + }finally{ + updateStatusBar("Look&Feelが更新されました"); + setBusy(false); + } + return; + } + }); + + return; + } + + /** + * 発言フィルタ画面を表示する。 + */ + private void actionShowFilter(){ + toggleWindow(this.filterFrame); + return; + } + + /** + * アカウント管理画面を表示する。 + */ + private void actionShowAccount(){ + if(this.accountFrame != null){ // show Toggle + toggleWindow(this.accountFrame); + return; + } + + this.accountFrame = new AccountPanel(this.topFrame, this.model); + this.accountFrame.pack(); + this.accountFrame.setVisible(true); + + this.windowMap.put(this.accountFrame, true); + + return; + } + + /** + * ログ表示画面を表示する。 + */ + private void actionShowLog(){ + toggleWindow(this.showlogFrame); + return; + } + + /** + * 発言エディタを表示する。 + */ + private void actionTalkPreview(){ + toggleWindow(this.talkPreview); + return; + } + + /** + * オプション設定画面を表示する。 + */ + private void actionOption(){ + AppSetting setting = Jindolf.getAppSetting(); + + FontInfo fontInfo = setting.getFontInfo(); + this.optionPanel.getFontChooser().setFontInfo(fontInfo); + + ProxyInfo proxyInfo = setting.getProxyInfo(); + this.optionPanel.getProxyChooser().setProxyInfo(proxyInfo); + + DialogPref dialogPref = setting.getDialogPref(); + this.optionPanel.getDialogPrefPanel().setDialogPref(dialogPref); + + this.optionPanel.setVisible(true); + if(this.optionPanel.isCanceled()) return; + + fontInfo = this.optionPanel.getFontChooser().getFontInfo(); + updateFontInfo(fontInfo); + + proxyInfo = this.optionPanel.getProxyChooser().getProxyInfo(); + updateProxyInfo(proxyInfo); + + dialogPref = this.optionPanel.getDialogPrefPanel().getDialogPref(); + updateDialogPref(dialogPref); + + return; + } + + /** + * フォント設定を変更する。 + * @param newFontInfo 新フォント設定 + */ + private void updateFontInfo(final FontInfo newFontInfo){ + AppSetting setting = Jindolf.getAppSetting(); + FontInfo oldInfo = setting.getFontInfo(); + + if(newFontInfo.equals(oldInfo)) return; + setting.setFontInfo(newFontInfo); + + this.topView.getTabBrowser().setFontInfo(newFontInfo); + this.talkPreview.setFontInfo(newFontInfo); + this.optionPanel.getFontChooser().setFontInfo(newFontInfo); + + return; + } + + /** + * プロクシ設定を変更する。 + * @param newProxyInfo 新プロクシ設定 + */ + private void updateProxyInfo(ProxyInfo newProxyInfo){ + AppSetting setting = Jindolf.getAppSetting(); + ProxyInfo oldProxyInfo = setting.getProxyInfo(); + + if(newProxyInfo.equals(oldProxyInfo)) return; + setting.setProxyInfo(newProxyInfo); + + for(Land land : this.model.getLandList()){ + ServerAccess server = land.getServerAccess(); + server.setProxy(newProxyInfo.getProxy()); + } + + return; + } + + /** + * 発言表示設定を更新する。 + * @param newDialogPref 表示設定 + */ + private void updateDialogPref(DialogPref newDialogPref){ + AppSetting setting = Jindolf.getAppSetting(); + DialogPref oldDialogPref = setting.getDialogPref(); + + if(newDialogPref.equals(oldDialogPref)) return; + setting.setDialogPref(newDialogPref); + + this.topView.getTabBrowser().setDialogPref(newDialogPref); + + return; + } + + /** + * 村ダイジェスト画面を表示する。 + */ + private void actionShowDigest(){ + TabBrowser browser = this.topView.getTabBrowser(); + final Village village = browser.getVillage(); + if(village == null) return; + + VillageState villageState = village.getState(); + if(( villageState != VillageState.EPILOGUE + && villageState != VillageState.GAMEOVER + ) || ! village.isValid() ){ + String message = "エピローグを正常に迎えていない村は\n" + +"ダイジェスト機能を利用できません"; + String title = "ダイジェスト不可 - " + Jindolf.TITLE; + JOptionPane pane = new JOptionPane(message, + JOptionPane.WARNING_MESSAGE, + JOptionPane.DEFAULT_OPTION ); + JDialog dialog = pane.createDialog(this.topFrame, title); + dialog.pack(); + dialog.setVisible(true); + dialog.dispose(); + return; + } + + if(this.digestPanel == null){ + this.digestPanel = new VillageDigest(this.topFrame); + this.digestPanel.pack(); + this.digestPanel.setSize(600, 550); + this.windowMap.put(this.digestPanel, false); + } + + final VillageDigest digest = this.digestPanel; + Executor executor = Executors.newCachedThreadPool(); + executor.execute(new Runnable(){ + public void run(){ + taskFullOpenAllPeriod(); + EventQueue.invokeLater(new Runnable(){ + public void run(){ + digest.setVillage(village); + digest.setVisible(true); + return; + } + }); + return; + } + }); + + return; + } + + /** + * 全日程の一括フルオープン。ヘビータスク版。 + */ + // TODO taskLoadAllPeriodtと一体化したい。 + private void taskFullOpenAllPeriod(){ + setBusy(true); + updateStatusBar("一括読み込み開始"); + try{ + TabBrowser browser = this.topView.getTabBrowser(); + Village village = browser.getVillage(); + if(village == null) return; + for(PeriodView periodView : browser.getPeriodViewList()){ + Period period = periodView.getPeriod(); + if(period == null) continue; + if(period.isFullOpen()) continue; + String message = + period.getDay() + + "日目のデータを読み込んでいます"; + updateStatusBar(message); + try{ + Period.parsePeriod(period, true); + }catch(IOException e){ + showNetworkError(village, e); + return; + } + periodView.showTopics(); + } + }finally{ + updateStatusBar("一括読み込み完了"); + setBusy(false); + } + return; + } + + /** + * 検索パネルを表示する。 + */ + private void actionShowFind(){ + this.findPanel.setVisible(true); + if(this.findPanel.isCanceled()){ + updateFindPanel(); + return; + } + if(this.findPanel.isBulkSearch()){ + bulkSearch(); + }else{ + regexSearch(); + } + return; + } + + /** + * 検索処理。 + */ + private void regexSearch(){ + Discussion discussion = currentDiscussion(); + if(discussion == null) return; + + RegexPattern regPattern = this.findPanel.getRegexPattern(); + int hits = discussion.setRegexPattern(regPattern); + + String hitMessage = "ï¼»" + hits + "]件ヒットしました"; + updateStatusBar(hitMessage); + + String loginfo = ""; + if(regPattern != null){ + Pattern pattern = regPattern.getPattern(); + if(pattern != null){ + loginfo = "正規表現 " + pattern.pattern() + " に"; + } + } + loginfo += hitMessage; + Jindolf.logger().info(loginfo); + + return; + } + + /** + * 一括検索処理。 + */ + private void bulkSearch(){ + Executor executor = Executors.newCachedThreadPool(); + executor.execute(new Runnable(){ + public void run(){ + taskBulkSearch(); + return; + } + }); + } + + /** + * 一括検索処理。ヘビータスク版。 + */ + private void taskBulkSearch(){ + taskLoadAllPeriod(); + int totalhits = 0; + RegexPattern regPattern = this.findPanel.getRegexPattern(); + StringBuilder hitDesc = new StringBuilder(); + TabBrowser browser = this.topView.getTabBrowser(); + for(PeriodView periodView : browser.getPeriodViewList()){ + Discussion discussion = periodView.getDiscussion(); + int hits = discussion.setRegexPattern(regPattern); + totalhits += hits; + + if(hits > 0){ + Period period = discussion.getPeriod(); + hitDesc.append(' ').append(period.getDay()).append("d:"); + hitDesc.append(hits).append("件"); + } + } + String hitMessage = + "ï¼»" + totalhits + "]件ヒットしました。" + + hitDesc.toString(); + updateStatusBar(hitMessage); + + String loginfo = ""; + if(regPattern != null){ + Pattern pattern = regPattern.getPattern(); + if(pattern != null){ + loginfo = "正規表現 " + pattern.pattern() + " に"; + } + } + loginfo += hitMessage; + Jindolf.logger().info(loginfo); + + return; + } + + /** + * 検索パネルに現在選択中のPeriodを反映させる。 + */ + private void updateFindPanel(){ + Discussion discussion = currentDiscussion(); + if(discussion == null) return; + RegexPattern pattern = discussion.getRegexPattern(); + this.findPanel.setRegexPattern(pattern); + return; + } + + /** + * 発言集計パネルを表示。 + */ + private void actionDaySummary(){ + PeriodView periodView = currentPeriodView(); + if(periodView == null) return; + + Period period = periodView.getPeriod(); + if(period == null) return; + + if(this.daySummaryPanel == null){ + this.daySummaryPanel = new DaySummary(this.topFrame); + this.daySummaryPanel.pack(); + this.daySummaryPanel.setSize(400, 500); + } + + this.daySummaryPanel.summaryPeriod(period); + this.daySummaryPanel.setVisible(true); + + this.windowMap.put(this.daySummaryPanel, false); + + return; + } + + /** + * 表示中PeriodをCSVファイルへエクスポートする。 + */ + private void actionDayExportCsv(){ + PeriodView periodView = currentPeriodView(); + if(periodView == null) return; + + Period period = periodView.getPeriod(); + if(period == null) return; + + File file = CsvExporter.exportPeriod(period, this.filterFrame); + if(file != null){ + String message = "CSVファイル(" + +file.getName() + +")へのエクスポートが完了しました"; + updateStatusBar(message); + } + + // TODO 長そうなジョブなら別スレッドにした方がいいか? + + return; + } + + /** + * 検索結果の次候補へジャンプ。 + */ + private void actionSearchNext(){ + Discussion discussion = currentDiscussion(); + if(discussion == null) return; + + discussion.nextHotTarget(); + + return; + } + + /** + * 検索結果の全候補へジャンプ。 + */ + private void actionSearchPrev(){ + Discussion discussion = currentDiscussion(); + if(discussion == null) return; + + discussion.prevHotTarget(); + + return; + } + + /** + * Period表示の強制再更新処理。 + */ + private void actionReloadPeriod(){ + updatePeriod(true); + return; + } + + /** + * 全日程の一括ロード。 + */ + private void actionLoadAllPeriod(){ + Executor executor = Executors.newCachedThreadPool(); + executor.execute(new Runnable(){ + public void run(){ + taskLoadAllPeriod(); + return; + } + }); + + return; + } + + /** + * 全日程の一括ロード。ヘビータスク版。 + */ + private void taskLoadAllPeriod(){ + setBusy(true); + updateStatusBar("一括読み込み開始"); + try{ + TabBrowser browser = this.topView.getTabBrowser(); + Village village = browser.getVillage(); + if(village == null) return; + for(PeriodView periodView : browser.getPeriodViewList()){ + Period period = periodView.getPeriod(); + if(period == null) continue; + String message = + period.getDay() + + "日目のデータを読み込んでいます"; + updateStatusBar(message); + try{ + Period.parsePeriod(period, false); + }catch(IOException e){ + showNetworkError(village, e); + return; + } + periodView.showTopics(); + } + }finally{ + updateStatusBar("一括読み込み完了"); + setBusy(false); + } + return; + } + + /** + * 村一覧の再読み込み。 + */ + private void actionReloadVillageList(){ + JTree tree = this.topView.getTreeView(); + TreePath path = tree.getSelectionPath(); + if(path == null) return; + + Land land = null; + for(int ct = 0; ct < path.getPathCount(); ct++){ + Object obj = path.getPathComponent(ct); + if(obj instanceof Land){ + land = (Land) obj; + break; + } + } + if(land == null) return; + + this.topView.showInitPanel(); + + execReloadVillageList(land); + + return; + } + + /** + * 選択文字列をクリップボードにコピーする。 + */ + private void actionCopySelected(){ + Discussion discussion = currentDiscussion(); + if(discussion == null) return; + + CharSequence copied = discussion.copySelected(); + if(copied == null) return; + + copied = StringUtils.suppressString(copied); + updateStatusBar( + "[" + copied + "]をクリップボードにコピーしました"); + return; + } + + /** + * 一発言のみクリップボードにコピーする。 + */ + private void actionCopyTalk(){ + Discussion discussion = currentDiscussion(); + if(discussion == null) return; + + CharSequence copied = discussion.copyTalk(); + if(copied == null) return; + + copied = StringUtils.suppressString(copied); + updateStatusBar( + "[" + copied + "]をクリップボードにコピーしました"); + return; + } + + /** + * アンカーにジャンプする。 + */ + private void actionJumpAnchor(){ + PeriodView periodView = currentPeriodView(); + if(periodView == null) return; + Discussion discussion = periodView.getDiscussion(); + + final TabBrowser browser = this.topView.getTabBrowser(); + final Village village = browser.getVillage(); + final Anchor anchor = discussion.getPopupedAnchor(); + if(anchor == null) return; + + Executor executor = Executors.newCachedThreadPool(); + executor.execute(new Runnable(){ + public void run(){ + setBusy(true); + updateStatusBar("ジャンプ先の読み込み中…"); + + if(anchor.hasTalkNo()){ + // TODO もう少し賢くならない? + taskLoadAllPeriod(); + } + + final List talkList; + try{ + talkList = village.getTalkListFromAnchor(anchor); + if(talkList == null || talkList.size() <= 0){ + updateStatusBar( + "アンカーのジャンプ先[" + + anchor.toString() + + "]が見つかりません"); + return; + } + + final Talk targetTalk = talkList.get(0); + final Period targetPeriod = targetTalk.getPeriod(); + final int tabIndex = targetPeriod.getDay() + 1; + final PeriodView target = browser.getPeriodView(tabIndex); + + EventQueue.invokeLater(new Runnable(){ + public void run(){ + browser.setSelectedIndex(tabIndex); + target.setPeriod(targetPeriod); + target.scrollToTalk(targetTalk); + return; + } + }); + updateStatusBar( + "アンカー[" + + anchor.toString() + + "]にジャンプしました"); + }catch(IOException e){ + updateStatusBar( + "アンカーの展開中にエラーが起きました"); + }finally{ + setBusy(false); + } + + return; + } + }); + + return; + } + + /** + * 指定した国の村一覧を読み込む。 + * @param land 国 + */ + private void execReloadVillageList(final Land land){ + final LandsTree treePanel = this.topView.getLandsTree(); + Executor executor = Executors.newCachedThreadPool(); + executor.execute(new Runnable(){ + public void run(){ + setBusy(true); + updateStatusBar("村一覧を読み込み中…"); + try{ + try{ + Controller.this.model.loadVillageList(land); + }catch(IOException e){ + showNetworkError(land, e); + } + treePanel.expandLand(land); + }finally{ + updateStatusBar("村一覧の読み込み完了"); + setBusy(false); + } + return; + } + }); + return; + } + + /** + * Period表示の更新処理。 + * @param force trueならPeriodデータを強制再読み込み。 + */ + private void updatePeriod(final boolean force){ + final TabBrowser tabBrowser = this.topView.getTabBrowser(); + final Village village = tabBrowser.getVillage(); + if(village == null) return; + setFrameTitle(village.getVillageFullName()); + + final PeriodView periodView = currentPeriodView(); + Discussion discussion = currentDiscussion(); + if(discussion == null) return; + discussion.setTopicFilter(this.filterFrame); + final Period period = discussion.getPeriod(); + if(period == null) return; + + Executor executor = Executors.newCachedThreadPool(); + executor.execute(new Runnable(){ + public void run(){ + setBusy(true); + try{ + boolean wasHot = loadPeriod(); + + if(wasHot && ! period.isHot() ){ + if( ! updatePeriodList() ) return; + } + + renderBrowser(); + }finally{ + setBusy(false); + } + return; + } + + private boolean loadPeriod(){ + updateStatusBar("1日分のデータを読み込んでいます…"); + boolean wasHot; + try{ + wasHot = period.isHot(); + try{ + Period.parsePeriod(period, force); + }catch(IOException e){ + showNetworkError(village, e); + } + }finally{ + updateStatusBar("1日分のデータを読み終わりました"); + } + return wasHot; + } + + private boolean updatePeriodList(){ + updateStatusBar("村情報を読み直しています…"); + try{ + Village.updateVillage(village); + }catch(IOException e){ + showNetworkError(village, e); + return false; + } + try{ + SwingUtilities.invokeAndWait(new Runnable(){ + public void run(){ + tabBrowser.setVillage(village); + return; + } + }); + }catch(Exception e){ + Jindolf.logger().fatal( + "タブ操作で致命的な障害が発生しました", e); + } + updateStatusBar("村情報を読み直しました…"); + return true; + } + + private void renderBrowser(){ + updateStatusBar("レンダリング中…"); + try{ + final int lastPos = periodView.getVerticalPosition(); + try{ + SwingUtilities.invokeAndWait(new Runnable(){ + public void run(){ + periodView.showTopics(); + return; + } + }); + }catch(Exception e){ + Jindolf.logger().fatal( + "ブラウザ表示で致命的な障害が発生しました", e); + } + EventQueue.invokeLater(new Runnable(){ + public void run(){ + periodView.setVerticalPosition(lastPos); + } + }); + }finally{ + updateStatusBar("レンダリング完了"); + } + return; + } + }); + + return; + } + + /** + * 発言フィルタの操作による更新処理。 + */ + private void filterChanged(){ + final Discussion discussion = currentDiscussion(); + if(discussion == null) return; + discussion.setTopicFilter(this.filterFrame); + + Executor executor = Executors.newCachedThreadPool(); + executor.execute(new Runnable(){ + public void run(){ + setBusy(true); + updateStatusBar("フィルタリング中…"); + try{ + discussion.filtering(); + }finally{ + updateStatusBar("フィルタリング完了"); + setBusy(false); + } + return; + } + }); + + return; + } + + /** + * 現在選択中のPeriodを内包するPeriodViewを返す。 + * @return PeriodView + */ + private PeriodView currentPeriodView(){ + TabBrowser tb = this.topView.getTabBrowser(); + PeriodView result = tb.currentPeriodView(); + return result; + } + + /** + * 現在選択中のPeriodを内包するDiscussionを返す。 + * @return Discussion + */ + private Discussion currentDiscussion(){ + PeriodView periodView = currentPeriodView(); + if(periodView == null) return null; + Discussion result = periodView.getDiscussion(); + return result; + } + + /** + * フレーム表示のトグル処理。 + * @param window フレーム + */ + private void toggleWindow(Window window){ + if(window == null) return; + + if(window instanceof Frame){ + Frame frame = (Frame) window; + int winState = frame.getExtendedState(); + boolean isIconified = (winState & Frame.ICONIFIED) != 0; + if(isIconified){ + winState &= ~(Frame.ICONIFIED); + frame.setExtendedState(winState); + frame.setVisible(true); + return; + } + } + + if(window.isVisible()){ + window.setVisible(false); + window.dispose(); + }else{ + window.setVisible(true); + } + return; + } + + /** + * ネットワークエラーを通知するモーダルダイアログを表示する。 + * OKボタンを押すまでこのメソッドは戻ってこない。 + * @param village 村 + * @param e ネットワークエラー + */ + public void showNetworkError(Village village, IOException e){ + Land land = village.getParentLand(); + showNetworkError(land, e); + return; + } + + /** + * ネットワークエラーを通知するモーダルダイアログを表示する。 + * OKボタンを押すまでこのメソッドは戻ってこない。 + * @param land 国 + * @param e ネットワークエラー + */ + public void showNetworkError(Land land, IOException e){ + Jindolf.logger().warn("ネットワークで障害が発生しました", e); + + ServerAccess server = land.getServerAccess(); + String message = + land.getLandDef().getLandName() + +"を運営するサーバとの間の通信で" + +"何らかのトラブルが発生しました。\n" + +"相手サーバのURLは [ " + server.getBaseURL() + " ] だよ。\n" + +"プロクシ設定は正しいかな?\n" + +"Webブラウザでも遊べないか確認してみてね!\n"; + + JOptionPane pane = new JOptionPane(message, + JOptionPane.WARNING_MESSAGE, + JOptionPane.DEFAULT_OPTION ); + + JDialog dialog = pane.createDialog(this.topFrame, + "通信異常発生 - " + Jindolf.TITLE); + + dialog.pack(); + dialog.setVisible(true); + dialog.dispose(); + + return; + } + + /** + * {@inheritDoc} + * ツリーリストで何らかの要素(国、村)がクリックされたときの処理。 + * @param event イベント {@inheritDoc} + */ + public void valueChanged(TreeSelectionEvent event){ + TreePath path = event.getNewLeadSelectionPath(); + if(path == null) return; + + Object selObj = path.getLastPathComponent(); + + if( selObj instanceof Land ){ + Land land = (Land)selObj; + setFrameTitle(land.getLandDef().getLandName()); + this.topView.showLandInfo(land); + this.actionManager.appearVillage(false); + this.actionManager.appearPeriod(false); + }else if( selObj instanceof Village ){ + final Village village = (Village)selObj; + + Executor executor = Executors.newCachedThreadPool(); + executor.execute(new Runnable(){ + public void run(){ + setBusy(true); + updateStatusBar("村情報を読み込み中…"); + + try{ + Village.updateVillage(village); + }catch(IOException e){ + showNetworkError(village, e); + return; + }finally{ + updateStatusBar("村情報の読み込み完了"); + setBusy(false); + } + + Controller.this.actionManager.appearVillage(true); + setFrameTitle(village.getVillageFullName()); + + EventQueue.invokeLater(new Runnable(){ + public void run(){ + Controller.this.topView.showVillageInfo(village); + return; + } + }); + + return; + } + }); + } + + return; + } + + /** + * {@inheritDoc} + * Periodがタブ選択されたときもしくは発言フィルタが操作されたときの処理。 + * @param event イベント {@inheritDoc} + */ + public void stateChanged(ChangeEvent event){ + Object source = event.getSource(); + + if(source == this.filterFrame){ + filterChanged(); + }else if(source instanceof TabBrowser){ + updateFindPanel(); + updatePeriod(false); + PeriodView periodView = currentPeriodView(); + if(periodView == null) this.actionManager.appearPeriod(false); + else this.actionManager.appearPeriod(true); + } + return; + } + + /** + * {@inheritDoc} + * 主にメニュー選択やボタン押下など。 + * @param e イベント {@inheritDoc} + */ + public void actionPerformed(ActionEvent e){ + if(this.isBusyNow) return; + + String cmd = e.getActionCommand(); + if(cmd.equals(ActionManager.CMD_ACCOUNT)){ + actionShowAccount(); + }else if(cmd.equals(ActionManager.CMD_EXIT)){ + actionExit(); + }else if(cmd.equals(ActionManager.CMD_COPY)){ + actionCopySelected(); + }else if(cmd.equals(ActionManager.CMD_SHOWFIND)){ + actionShowFind(); + }else if(cmd.equals(ActionManager.CMD_SEARCHNEXT)){ + actionSearchNext(); + }else if(cmd.equals(ActionManager.CMD_SEARCHPREV)){ + actionSearchPrev(); + }else if(cmd.equals(ActionManager.CMD_ALLPERIOD)){ + actionLoadAllPeriod(); + }else if(cmd.equals(ActionManager.CMD_SHOWDIGEST)){ + actionShowDigest(); + }else if(cmd.equals(ActionManager.CMD_WEBVILL)){ + actionShowWebVillage(); + }else if(cmd.equals(ActionManager.CMD_WEBWIKI)){ + actionShowWebWiki(); + }else if(cmd.equals(ActionManager.CMD_WEBCAST)){ + actionShowWebCast(); + }else if(cmd.equals(ActionManager.CMD_RELOAD)){ + actionReloadPeriod(); + }else if(cmd.equals(ActionManager.CMD_DAYSUMMARY)){ + actionDaySummary(); + }else if(cmd.equals(ActionManager.CMD_DAYEXPCSV)){ + actionDayExportCsv(); + }else if(cmd.equals(ActionManager.CMD_WEBDAY)){ + actionShowWebDay(); + }else if(cmd.equals(ActionManager.CMD_OPTION)){ + actionOption(); + }else if(cmd.equals(ActionManager.CMD_LANDF)){ + actionChangeLaF(); + }else if(cmd.equals(ActionManager.CMD_SHOWFILT)){ + actionShowFilter(); + }else if(cmd.equals(ActionManager.CMD_SHOWEDIT)){ + actionTalkPreview(); + }else if(cmd.equals(ActionManager.CMD_SHOWLOG)){ + actionShowLog(); + }else if(cmd.equals(ActionManager.CMD_HELPDOC)){ + actionHelp(); + }else if(cmd.equals(ActionManager.CMD_SHOWPORTAL)){ + actionShowPortal(); + }else if(cmd.equals(ActionManager.CMD_ABOUT)){ + actionAbout(); + }else if(cmd.equals(ActionManager.CMD_VILLAGELIST)){ + actionReloadVillageList(); + }else if(cmd.equals(ActionManager.CMD_COPYTALK)){ + actionCopyTalk(); + }else if(cmd.equals(ActionManager.CMD_JUMPANCHOR)){ + actionJumpAnchor(); + }else if(cmd.equals(ActionManager.CMD_WEBTALK)){ + actionShowWebTalk(); + } + return; + } + + /** + * {@inheritDoc} + * 村選択ツリーリストが畳まれるとき呼ばれる。 + * @param event ツリーイベント {@inheritDoc} + */ + public void treeWillCollapse(TreeExpansionEvent event){ + return; + } + + /** + * {@inheritDoc} + * 村選択ツリーリストが展開されるとき呼ばれる。 + * @param event ツリーイベント {@inheritDoc} + */ + public void treeWillExpand(TreeExpansionEvent event){ + if(!(event.getSource() instanceof JTree)){ + return; + } + + TreePath path = event.getPath(); + Object lastObj = path.getLastPathComponent(); + if(!(lastObj instanceof Land)){ + return; + } + final Land land = (Land) lastObj; + if(land.getVillageCount() > 0){ + return; + } + + execReloadVillageList(land); + + return; + } + + /** + * {@inheritDoc} + * @param event {@inheritDoc} + */ + public void anchorHitted(AnchorHitEvent event){ + PeriodView periodView = currentPeriodView(); + if(periodView == null) return; + Period period = periodView.getPeriod(); + if(period == null) return; + final Village village = period.getVillage(); + + final TalkDraw talkDraw = event.getTalkDraw(); + final Anchor anchor = event.getAnchor(); + final Discussion discussion = periodView.getDiscussion(); + + Executor executor = Executors.newCachedThreadPool(); + executor.execute(new Runnable(){ + public void run(){ + setBusy(true); + updateStatusBar("アンカーの展開中…"); + + if(anchor.hasTalkNo()){ + // TODO もう少し賢くならない? + taskLoadAllPeriod(); + } + + final List talkList; + try{ + talkList = village.getTalkListFromAnchor(anchor); + if(talkList == null || talkList.size() <= 0){ + updateStatusBar( + "アンカーの展開先[" + + anchor.toString() + + "]が見つかりません"); + return; + } + EventQueue.invokeLater(new Runnable(){ + public void run(){ + talkDraw.showAnchorTalks(anchor, talkList); + discussion.layoutRows(); + return; + } + }); + updateStatusBar( + "アンカー[" + + anchor.toString() + + "]の展開完了"); + }catch(IOException e){ + updateStatusBar( + "アンカーの展開中にエラーが起きました"); + }finally{ + setBusy(false); + } + + return; + } + }); + + return; + } + + /** + * ヘビーなタスク実行をアピール。 + * プログレスバーとカーソルの設定を行う。 + * @param isBusy trueならプログレスバーのアニメ開始&WAITカーソル。 + * falseなら停止&通常カーソル。 + */ + private void setBusy(final boolean isBusy){ + this.isBusyNow = isBusy; + + Runnable microJob = new Runnable(){ + public void run(){ + Cursor cursor; + if(isBusy){ + cursor = Cursor.getPredefinedCursor(Cursor.WAIT_CURSOR); + }else{ + cursor = Cursor.getDefaultCursor(); + } + + Component glass = Controller.this.topFrame.getGlassPane(); + glass.setCursor(cursor); + glass.setVisible(isBusy); + Controller.this.topView.setBusy(isBusy); + + return; + } + }; + + if(SwingUtilities.isEventDispatchThread()){ + microJob.run(); + }else{ + try{ + SwingUtilities.invokeAndWait(microJob); + }catch(Exception e){ + Jindolf.logger().fatal("ビジー処理で失敗", e); + } + } + + return; + } + + /** + * ステータスバーを更新する。 + * @param message メッセージ + */ + private void updateStatusBar(String message){ + this.topView.updateSysMessage(message); + } + + /** + * トップフレームのタイトルを設定する。 + * タイトルは指定された国or村名 + " - Jindolf" + * @param name 国or村名 + */ + private void setFrameTitle(CharSequence name){ + String title = Jindolf.TITLE; + + if(name != null && name.length() > 0){ + title = name + " - " + title; + } + + this.topFrame.setTitle(title); + + return; + } + + /** + * アプリ正常終了処理。 + */ + private void shutdown(){ + this.findPanel.saveHistory(); + this.talkPreview.saveDraft(); + Jindolf.getAppSetting().saveConfig(); + Jindolf.exit(0); + return; + } + +} diff --git a/src/main/java/jp/sourceforge/jindolf/CsvExporter.java b/src/main/java/jp/sourceforge/jindolf/CsvExporter.java new file mode 100644 index 0000000..4481181 --- /dev/null +++ b/src/main/java/jp/sourceforge/jindolf/CsvExporter.java @@ -0,0 +1,552 @@ +/* + * CSV file exporter + * + * Copyright(c) 2009 olyutorskii + * $Id: CsvExporter.java 953 2009-12-06 16:42:14Z olyutorskii $ + */ + +package jp.sourceforge.jindolf; + +import java.awt.Component; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.Charset; +import java.nio.charset.CharsetEncoder; +import java.util.LinkedList; +import java.util.List; +import javax.swing.BorderFactory; +import javax.swing.JComboBox; +import javax.swing.JComponent; +import javax.swing.JFileChooser; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.border.Border; +import javax.swing.filechooser.FileFilter; +import jp.sourceforge.jindolf.corelib.TalkType; + +/** + * 任意のPeriodの発言内容をCSVファイルへエクスポートする。 + * according to RFC4180 (text/csv) + * @see RFC4180 + */ +public final class CsvExporter{ + + private static final String[] ENCNAMES = { + "UTF-8", + + "ISO-2022-JP", + "ISO-2022-JP-2", + "ISO-2022-JP-3", + "ISO-2022-JP-2004", + + "EUC-JP", + "x-euc-jp-linux", + "x-eucJP-Open", + + "Shift_JIS", + "windows-31j", + "x-MS932_0213", + "x-SJIS_0213", + "x-PCK", + }; + private static final String JPCHECK = + "[]09AZ" + + "あんアンアンゐゑヵヶヴヰヱヮ" + + "亜瑤凜熙壷壺尭堯" + + "å³ " + + "〒╋"; + private static final String CSVEXT = ".csv"; + private static final char CR = '\r'; + private static final char LF = '\n'; + private static final String CRLF = CR +""+ LF; + private static final int BUFSIZ = 1024; + + private static final List CHARSET_LIST = buildCharsetList(); + private static final FileFilter CSV_FILTER = new CsvFileFilter(); + private static final JComboBox encodeBox = new JComboBox(); + private static final JFileChooser chooser = buildChooser(); + // TODO staticなGUIパーツってどうなんだ… + + /** + * Charsetが日本語エンコーダを持っているか確認する。 + * @param cs Charset + * @return 日本語エンコーダを持っていればtrue + */ + private static boolean hasJPencoder(Charset cs){ + if( ! cs.canEncode() ) return false; + CharsetEncoder encoder = cs.newEncoder(); + try{ + if(encoder.canEncode(JPCHECK)) return true; + }catch(Exception e){ + return false; + // 一部JRE1.5系の「x-euc-jp-linux」エンコーディング実装には + // canEncode()が例外を投げるバグがあるので、その対処。 + } + return false; + } + + /** + * 日本語Charset一覧を生成する。 + * @return 日本語Charset一覧 + */ + private static List buildCharsetList(){ + List csList = new LinkedList(); + for(String name : ENCNAMES){ + if( ! Charset.isSupported(name) ) continue; + Charset cs = Charset.forName(name); + + if(csList.contains(cs)) continue; + + if( ! hasJPencoder(cs) ) continue; + + csList.add(cs); + } + + Charset defcs = Charset.defaultCharset(); + if( defcs.name().equals("windows-31j") + && Charset.isSupported("Shift_JIS") ){ + defcs = Charset.forName("Shift_JIS"); + } + + if( hasJPencoder(defcs) || csList.size() <= 0 ){ + if(csList.contains(defcs)){ + csList.remove(defcs); + } + csList.add(0, defcs); + } + + return csList; + } + + /** + * チューザーをビルドする。 + * @return チューザー + */ + private static JFileChooser buildChooser(){ + JFileChooser result = new JFileChooser(); + + result.setFileSelectionMode(JFileChooser.FILES_ONLY); + result.setMultiSelectionEnabled(false); + result.setFileHidingEnabled(true); + + result.setAcceptAllFileFilterUsed(true); + + result.setFileFilter(CSV_FILTER); + + JComponent accessory = buildAccessory(); + result.setAccessory(accessory); + + return result; + } + + /** + * チューザのアクセサリを生成する。 + * エンコード指定のコンボボックス。 + * @return アクセサリ + */ + private static JComponent buildAccessory(){ + for(Charset cs : CHARSET_LIST){ + encodeBox.addItem(cs); + } + + Border border = BorderFactory.createTitledBorder("出力エンコード"); + encodeBox.setBorder(border); + + JPanel accessory = new JPanel(); + GridBagLayout layout = new GridBagLayout(); + GridBagConstraints constraints = new GridBagConstraints(); + accessory.setLayout(layout); + + constraints.insets = new Insets(3, 3, 3, 3); + constraints.gridwidth = GridBagConstraints.REMAINDER; + constraints.fill = GridBagConstraints.NONE; + constraints.weightx = 0.0; + constraints.weighty = 0.0; + constraints.anchor = GridBagConstraints.NORTHWEST; + + accessory.add(encodeBox, constraints); + + constraints.fill = GridBagConstraints.BOTH; + constraints.weightx = 1.0; + constraints.weighty = 1.0; + + accessory.add(new JPanel(), constraints); // dummy + + return accessory; + } + + /** + * ファイルに書き込めない/作れないエラー用のダイアログを表示する。 + * @param file 書き込もうとしたファイル。 + */ + private static void writeError(File file){ + Component parent = null; + String title = "ファイル書き込みエラー"; + String message = "ファイル「" + file.toString() + "」\n" + +"に書き込むことができません。"; + + JOptionPane.showMessageDialog(parent, message, title, + JOptionPane.ERROR_MESSAGE ); + + return; + } + + /** + * ファイル上書き確認ダイアログを表示する。 + * @param file 上書き対象ファイル + * @return 上書きOKが指示されたらtrue + */ + private static boolean confirmOverwrite(File file){ + Component parent = null; + String title = "上書き確認"; + String message = "既存のファイル「" + file.toString() + "」\n" + +"を上書きしようとしています。続けますか?"; + + int confirm = JOptionPane.showConfirmDialog( + parent, message, title, + JOptionPane.WARNING_MESSAGE, + JOptionPane.OK_CANCEL_OPTION ); + + if(confirm == JOptionPane.OK_OPTION) return true; + + return false; + } + + /** + * チューザーのタイトルを設定する。 + * @param period エクスポート対象の日 + */ + private static void setTitle(Period period){ + Village village = period.getVillage(); + String villageName = village.getVillageName(); + String title = villageName + "村 " + period.getCaption(); + title += "の発言をCSVファイルへエクスポートします"; + chooser.setDialogTitle(title); + return; + } + + /** + * エクスポート先ファイルの名前を生成する。 + * @param period エクスポート対象の日 + * @return エクスポートファイル名 + */ + private static String createUniqueFileName(Period period){ + Village village = period.getVillage(); + String villageName = village.getVillageName(); + + String base = "JIN_" + villageName; + + switch(period.getType()){ + case PROLOGUE: + base += "_Prologue"; + break; + case EPILOGUE: + base += "_Epilogue"; + break; + case PROGRESS: + base += "_Day"; + base += period.getDay(); + break; + default: + assert false; + break; + } + + File saveFile; + String csvName; + int serial = 1; + do{ + csvName = base; + if(serial > 1){ + csvName += "("+ serial +")"; + } + serial++; + csvName += CSVEXT; + + File current = chooser.getCurrentDirectory(); + saveFile = new File(current, csvName); + }while(saveFile.exists()); + + return csvName; + } + + /** + * Period情報をダンプする。 + * @param out 格納先 + * @param period ダンプ対象Period + * @param topicFilter 発言フィルタ + * @throws java.io.IOException 出力エラー + */ + private static void dumpPeriod(Appendable out, + Period period, + TopicFilter topicFilter) + throws IOException{ + String day = String.valueOf(period.getDay()); + + List topicList = period.getTopicList(); + for(Topic topic : topicList){ + if( ! (topic instanceof Talk) ) continue; + Talk talk = (Talk) topic; + if(talk.getTalkCount() <= 0) continue; + + if(topicFilter.isFiltered(talk)) continue; + + Avatar avatar = talk.getAvatar(); + + String name = avatar.getName(); + int hour = talk.getHour(); + int minute = talk.getMinute(); + TalkType type = talk.getTalkType(); + CharSequence dialog = talk.getDialog(); + + out.append(name).append(','); + + out.append(day).append(','); + + out.append(Character.forDigit(hour / 10, 10)); + out.append(Character.forDigit(hour % 10, 10)); + out.append(':'); + out.append(Character.forDigit(minute / 10, 10)); + out.append(Character.forDigit(minute % 10, 10)); + out.append(','); + + switch(type){ + case PUBLIC: out.append("say"); break; + case PRIVATE: out.append("think"); break; + case WOLFONLY: out.append("whisper"); break; + case GRAVE: out.append("groan"); break; + default: assert false; break; + } + out.append(','); + + escapeCSV(out, dialog); + out.append(CRLF); + } + + return; + } + + /** + * ダイアログ操作に従いPeriodをエクスポートする。 + * @param period エクスポート対象のPeriod + * @param topicFilter 発言フィルタ + * @return エクスポートしたファイル + */ + public static File exportPeriod(Period period, TopicFilter topicFilter){ + setTitle(period); + + String uniqName = createUniqueFileName(period); + File uniqFile = new File(uniqName); + chooser.setSelectedFile(uniqFile); + + int result = chooser.showSaveDialog(null); + + if(result != JFileChooser.APPROVE_OPTION) return null; + + File selected = chooser.getSelectedFile(); + + if( ! hasExtent(selected.getName()) ){ + FileFilter filter = chooser.getFileFilter(); + if(filter == CSV_FILTER){ + String path = selected.getPath(); + path += CSVEXT; + selected = new File(path); + } + } + + if(selected.exists()){ + if( ! selected.isFile() || ! selected.canWrite() ){ + writeError(selected); + return null; + } + boolean confirmed = confirmOverwrite(selected); + if( ! confirmed ) return null; + }else{ + boolean created; + try{ + created = selected.createNewFile(); + }catch(IOException e){ + writeError(selected); + return null; + } + + if( ! created ){ + boolean confirmed = confirmOverwrite(selected); + if( ! confirmed ) return null; + } + } + + OutputStream os; + try{ + os = new FileOutputStream(selected); + }catch(FileNotFoundException e){ + writeError(selected); + return null; + } + os = new BufferedOutputStream(os, BUFSIZ); + + Charset cs = (Charset)( encodeBox.getSelectedItem() ); + + boolean hasIOError = false; + Writer writer = new OutputStreamWriter(os, cs); + try{ + dumpPeriod(writer, period, topicFilter); + }catch(IOException e){ + hasIOError = true; + }finally{ + try{ + writer.close(); + }catch(IOException e){ + hasIOError = true; + } + } + if(hasIOError) writeError(selected); + + return selected; + } + + /** + * CSV用のエスケープシーケンス処理を行う。 + * RFC4180準拠。 + * @param app 格納先 + * @param seq エスケープシーケンス対象 + * @return appと同じもの + * @throws java.io.IOException 出力エラー + */ + public static Appendable escapeCSV(Appendable app, CharSequence seq) + throws IOException{ + app.append('"'); + + int length = seq.length(); + + for(int pos = 0; pos < length; pos++){ + char ch = seq.charAt(pos); + switch(ch){ + case '"': + app.append("\"\""); + continue; + case '\n': + app.append(CRLF); + continue; + default: + app.append(ch); + break; + } + } + + app.append('"'); + + return app; + } + + /** + * ファイル名が任意の拡張子を持つか判定する。 + * 英字大小は同一視される。 + * 拡張子の前は必ず一文字以上何かがなければならない。 + * @param filename ファイル名 + * @param extent '.'で始まる拡張子文字列 + * @return 指定された拡張子を持つならtrue + */ + public static boolean hasExtent(CharSequence filename, + CharSequence extent ){ + int flength = filename.length(); + int elength = extent .length(); + if(elength < 2) return false; + if(flength <= elength) return false; + + if(filename.charAt(0) == '.') return false; + + int offset = flength - elength; + assert offset > 0; + + for(int pos = 0; pos < elength; pos++){ + char ech = Character.toLowerCase(extent .charAt(pos )); + char fch = Character.toLowerCase(filename.charAt(pos + offset)); + if(fch != ech) return false; + } + + return true; + } + + /** + * パス名抜きのファイル名が拡張子を持つか判定する。 + * 先頭が.で始まるファイル名は拡張子を持たない。 + * 末尾が.で終わるファイル名は拡張子を持たない。 + * それ以外の.を含むファイル名は拡張子を持つとみなす。 + * @param filename パス名抜きのファイル名 + * @return 拡張子を持っていればtrue + */ + public static boolean hasExtent(CharSequence filename){ + int length = filename.length(); + if(length < 3) return false; + + if(filename.charAt(0) == '.') return false; + int lastPos = length - 1; + if(filename.charAt(lastPos) == '.') return false; + + for(int pos = 1; pos <= lastPos - 1; pos++){ + char ch = filename.charAt(pos); + if(ch == '.') return true; + } + + return false; + } + + /** + * 隠しコンストラクタ。 + */ + private CsvExporter(){ + assert false; + throw new AssertionError(); + } + + /** + * CSVファイル表示用フィルタ。 + * 名前が「*.csv」の通常ファイルとディレクトリのみ表示させる。 + * ※ 表示の可否を問うものであって、選択の可否を問うものではない。 + */ + private static class CsvFileFilter extends FileFilter{ + + /** + * コンストラクタ。 + */ + public CsvFileFilter(){ + super(); + return; + } + + /** + * {@inheritDoc} + * @param file {@inheritDoc} + * @return {@inheritDoc} + */ + public boolean accept(File file){ + if(file.isDirectory()) return true; + if( ! file.isFile() ) return false; + + if( ! hasExtent(file.getName(), CSVEXT) ) return false; + + return true; + } + + /** + * {@inheritDoc} + * @return {@inheritDoc} + */ + public String getDescription(){ + return "CSVファイル (*.csv)"; + } + } + + // TODO SecurityExceptionの捕捉 + // 書き込み中のファイルロック +} diff --git a/src/main/java/jp/sourceforge/jindolf/DaySummary.java b/src/main/java/jp/sourceforge/jindolf/DaySummary.java new file mode 100644 index 0000000..bdc8636 --- /dev/null +++ b/src/main/java/jp/sourceforge/jindolf/DaySummary.java @@ -0,0 +1,510 @@ +/* + * summary of day panel + * + * Copyright(c) 2008 olyutorskii + * $Id: DaySummary.java 888 2009-11-04 06:23:35Z olyutorskii $ + */ + +package jp.sourceforge.jindolf; + +import java.awt.Color; +import java.awt.Component; +import java.awt.Container; +import java.awt.Dimension; +import java.awt.Frame; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Image; +import java.awt.Insets; +import java.awt.LayoutManager; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.awt.event.ItemEvent; +import java.awt.event.ItemListener; +import java.awt.event.WindowEvent; +import java.awt.event.WindowListener; +import java.text.NumberFormat; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.SortedSet; +import java.util.TreeSet; +import javax.swing.ImageIcon; +import javax.swing.JButton; +import javax.swing.JComboBox; +import javax.swing.JDialog; +import javax.swing.JLabel; +import javax.swing.JScrollPane; +import javax.swing.JSeparator; +import javax.swing.JTable; +import javax.swing.ListSelectionModel; +import javax.swing.SwingConstants; +import javax.swing.table.DefaultTableCellRenderer; +import javax.swing.table.DefaultTableModel; +import javax.swing.table.TableColumn; +import javax.swing.table.TableColumnModel; +import jp.sourceforge.jindolf.corelib.TalkType; + +/** + * その日ごとの集計。 + */ +@SuppressWarnings("serial") +public class DaySummary extends JDialog + implements WindowListener, ActionListener, ItemListener{ + private static final String FRAMETITLE = + "発言集計 - " + Jindolf.TITLE; + private static final NumberFormat AVERAGE_FORM; + private static final String PUBTALK = "白発言"; + private static final String WOLFTALK = "赤発言"; + private static final String GRAVETALK = "青発言"; + private static final String PRVTALK = "灰発言"; + private static final String ALLTALK = "全発言"; + private static final int HORIZONTAL_GAP = 5; + private static final int VERTICAL_GAP = 1; + private static final Color COLOR_ALL = new Color(0xffff80); + + static{ + AVERAGE_FORM = NumberFormat.getInstance(); + AVERAGE_FORM.setMaximumFractionDigits(1); + AVERAGE_FORM.setMinimumFractionDigits(1); + } + + /** + * 初期のデータモデルを生成する。 + * @return データモデル + */ + private static DefaultTableModel createInitModel(){ + DefaultTableModel result; + result = new DefaultTableModel(); + + Object[] rowHeads = {"名前", "発言回数", "平均文字列長", "最終発言"}; + result.setColumnCount(rowHeads.length); + result.setColumnIdentifiers(rowHeads); + + return result; + } + + private final DefaultTableModel tableModel; + private final TableColumn avatarColumn; + + private final JTable tableComp; + private final JComboBox typeSelector = new JComboBox(); + private final JButton closeButton = new JButton("閉じる"); + private final JLabel caption = new JLabel(); + private final JLabel totalSum = new JLabel(); + + private TalkType talkFilter; + private Period period; + + /** + * コンストラクタ。 + * 集計結果を表示するモーダルダイアログを生成する。 + * @param owner オーナー + */ + public DaySummary(Frame owner){ + super(owner, FRAMETITLE, true); + + GUIUtils.modifyWindowAttributes(this, true, false, true); + + setDefaultCloseOperation(DO_NOTHING_ON_CLOSE); + addWindowListener(this); + + this.tableModel = createInitModel(); + this.tableComp = new JTable(); + this.tableComp.setModel(this.tableModel); + this.tableComp.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + this.tableComp.setIntercellSpacing( + new Dimension(HORIZONTAL_GAP, VERTICAL_GAP) ); + this.tableComp.setDefaultEditor(Object.class, null); + this.tableComp.setDefaultRenderer(Object.class, new CustomRenderer()); + this.tableComp.setShowGrid(true); + + TableColumnModel tcolModel = this.tableComp.getColumnModel(); + + this.avatarColumn = tcolModel.getColumn(0); + + DefaultTableCellRenderer renderer; + + renderer = new DefaultTableCellRenderer(); + renderer.setHorizontalAlignment(SwingConstants.RIGHT); + tcolModel.getColumn(1).setCellRenderer(renderer); + + renderer = new DefaultTableCellRenderer(); + renderer.setHorizontalAlignment(SwingConstants.RIGHT); + tcolModel.getColumn(2).setCellRenderer(renderer); + + renderer = new DefaultTableCellRenderer(); + renderer.setHorizontalAlignment(SwingConstants.RIGHT); + tcolModel.getColumn(3).setCellRenderer(renderer); + + this.typeSelector.addItem(PUBTALK); + this.typeSelector.addItem(WOLFTALK); + this.typeSelector.addItem(GRAVETALK); + this.typeSelector.addItem(PRVTALK); + this.typeSelector.addItem(ALLTALK); + + this.closeButton.addActionListener(this); + this.typeSelector.addItemListener(this); + + this.typeSelector.setSelectedItem(null); + this.typeSelector.setSelectedItem(PUBTALK); + + design(); + + clearModel(); + + return; + } + + /** + * テーブルをクリアする。 + */ + private void clearModel(){ + int rows = this.tableModel.getRowCount(); + for(int ct = 1; ct <= rows; ct++){ + this.tableModel.removeRow(0); + } + } + + /** + * 行を追加する。 + * @param avatar アバター + * @param talkCount 発言回数 + * @param totalChars 発言文字総数 + * @param lastTime 最終発言時刻 + */ + private void appendRow(Avatar avatar, + Integer talkCount, + Integer totalChars, + String lastTime ){ + String talks = talkCount + " 回"; + + double average; + if(talkCount <= 0) average = 0.0; + else average = (double)totalChars / (double)talkCount; + String chars = AVERAGE_FORM.format(average) + " 文字"; + + Object[] row = {avatar, talks, chars, lastTime}; + int rowIndex = this.tableModel.getRowCount(); + + this.tableModel.insertRow(rowIndex, row); + + return; + } + + /** + * デザインを行う。 + */ + private void design(){ + Container content = getContentPane(); + + LayoutManager layout = new GridBagLayout(); + GridBagConstraints constraints = new GridBagConstraints(); + content.setLayout(layout); + + constraints.insets = new Insets(5, 5, 5, 5); + + constraints.gridwidth = 1; + constraints.fill = GridBagConstraints.NONE; + constraints.anchor = GridBagConstraints.WEST; + content.add(this.caption, constraints); + + constraints.gridwidth = GridBagConstraints.REMAINDER; + constraints.fill = GridBagConstraints.NONE; + content.add(this.typeSelector, constraints); + + JScrollPane scroller = new JScrollPane(this.tableComp); + constraints.weightx = 1.0; + constraints.weighty = 1.0; + constraints.fill = GridBagConstraints.BOTH; + content.add(scroller, constraints); + + constraints.weightx = 0.0; + constraints.weighty = 0.0; + constraints.fill = GridBagConstraints.NONE; + constraints.anchor = GridBagConstraints.WEST; + content.add(this.totalSum, constraints); + + constraints.weightx = 1.0; + constraints.weighty = 0.0; + constraints.fill = GridBagConstraints.HORIZONTAL; + content.add(new JSeparator(), constraints); + + constraints.weightx = 0.0; + constraints.weighty = 0.0; + constraints.fill = GridBagConstraints.NONE; + constraints.anchor = GridBagConstraints.EAST; + content.add(this.closeButton, constraints); + + return; + } + + /** + * 与えられたPeriodで集計を更新する。 + * @param newPeriod 日 + */ + public void summaryPeriod(Period newPeriod){ + this.period = newPeriod; + summaryPeriod(); + } + + /** + * 集計を更新する。 + */ + private void summaryPeriod(){ + clearModel(); + + if(this.period == null) return; + + SortedSet avatarSet = new TreeSet(); + Map talkCount = new HashMap(); + Map totalChars = new HashMap(); + Map lastTalk = new HashMap(); + + List topicList = this.period.getTopicList(); + for(Topic topic : topicList){ + if( ! (topic instanceof Talk)) continue; + Talk talk = (Talk) topic; + if(talk.getTalkCount() <= 0) continue; + if( this.talkFilter != null + && talk.getTalkType() != this.talkFilter) continue; + + Avatar avatar = talk.getAvatar(); + + Integer counts = talkCount.get(avatar); + if(counts == null) counts = Integer.valueOf(0); + counts++; + talkCount.put(avatar, counts); + + Integer total = totalChars.get(avatar); + if(total == null) total = Integer.valueOf(0); + total += talk.getTotalChars(); + totalChars.put(avatar, total); + + lastTalk.put(avatar, talk); + + avatarSet.add(avatar); + } + + int sum = 0; + for(Avatar avatar : avatarSet){ + Integer counts = talkCount.get(avatar); + Integer total = totalChars.get(avatar); + String lastTime = lastTalk.get(avatar).getAnchorNotation(); + appendRow(avatar, counts, total, lastTime); + sum += counts; + } + + this.totalSum.setText("合計:" + sum + " 発言"); + + Village village = this.period.getVillage(); + String villageName = village.getVillageName(); + String periodCaption = this.period.getCaption(); + this.caption.setText(villageName + "村 " + periodCaption); + + return; + } + + /** + * {@inheritDoc} + * @param event {@inheritDoc} + */ + public void windowActivated(WindowEvent event){ + return; + } + + /** + * {@inheritDoc} + * @param event {@inheritDoc} + */ + public void windowDeactivated(WindowEvent event){ + return; + } + + /** + * {@inheritDoc} + * @param event {@inheritDoc} + */ + public void windowIconified(WindowEvent event){ + return; + } + + /** + * {@inheritDoc} + * @param event {@inheritDoc} + */ + public void windowDeiconified(WindowEvent event){ + return; + } + + /** + * {@inheritDoc} + * @param event {@inheritDoc} + */ + public void windowOpened(WindowEvent event){ + return; + } + + /** + * {@inheritDoc} + * ダイアログのクローズボタン押下処理を行う。 + * @param event ウィンドウ変化イベント {@inheritDoc} + */ + public void windowClosing(WindowEvent event){ + close(); + return; + } + + /** + * {@inheritDoc} + * @param event {@inheritDoc} + */ + public void windowClosed(WindowEvent event){ + return; + } + + /** + * {@inheritDoc} + * クローズボタン押下処理。 + * @param event イベント {@inheritDoc} + */ + public void actionPerformed(ActionEvent event){ + if(event.getSource() != this.closeButton) return; + close(); + return; + } + + /** + * {@inheritDoc} + * コンボボックス操作処理。 + * @param event イベント {@inheritDoc} + */ + public void itemStateChanged(ItemEvent event){ + if(event.getStateChange() != ItemEvent.SELECTED) return; + + Object selected = this.typeSelector.getSelectedItem(); + if (selected == PUBTALK) this.talkFilter = TalkType.PUBLIC; + else if(selected == WOLFTALK) this.talkFilter = TalkType.WOLFONLY; + else if(selected == GRAVETALK) this.talkFilter = TalkType.GRAVE; + else if(selected == PRVTALK) this.talkFilter = TalkType.PRIVATE; + else if(selected == ALLTALK) this.talkFilter = null; + + summaryPeriod(); + + return; + } + + /** + * このパネルを閉じる。 + */ + private void close(){ + clearModel(); + this.period = null; + setVisible(false); + return; + } + + /** + * Avatar 顔イメージ描画用カスタムセルレンダラ。 + */ + private class CustomRenderer extends DefaultTableCellRenderer{ + + /** + * コンストラクタ。 + */ + public CustomRenderer(){ + super(); + return; + } + + /** + * {@inheritDoc} + * セルに{@link Avatar}がきたら顔アイコンと名前を表示する。 + * @param value {@inheritDoc} + */ + @Override + public void setValue(Object value){ + if(value instanceof Avatar){ + Avatar avatar = (Avatar) value; + + Village village = DaySummary.this.period.getVillage(); + Image image = village.getAvatarFaceImage(avatar); + if(image == null) image = village.getGraveImage(); + if(image != null){ + ImageIcon icon = new ImageIcon(image); + setIcon(icon); + } + + setText(avatar.getName()); + + Dimension prefSize = getPreferredSize(); + + int cellHeight = VERTICAL_GAP * 2 + prefSize.height; + if(DaySummary.this.tableComp.getRowHeight() < cellHeight){ + DaySummary.this.tableComp.setRowHeight(cellHeight); + } + + int cellWidth = HORIZONTAL_GAP * 2 + prefSize.width; + if( DaySummary.this.avatarColumn.getPreferredWidth() + < cellWidth ){ + DaySummary.this.avatarColumn.setPreferredWidth(cellWidth); + } + + return; + } + + super.setValue(value); + + return; + } + + /** + * {@inheritDoc} + * 統計種別によってセル色を変える。 + * @param table {@inheritDoc} + * @param value {@inheritDoc} + * @param isSelected {@inheritDoc} + * @param hasFocus {@inheritDoc} + * @param row {@inheritDoc} + * @param column {@inheritDoc} + * @return {@inheritDoc} + */ + @Override + public Component getTableCellRendererComponent(JTable table, + Object value, + boolean isSelected, + boolean hasFocus, + int row, + int column ){ + Component result = super.getTableCellRendererComponent(table, + value, + isSelected, + hasFocus, + row, + column ); + + Object selected = DaySummary.this.typeSelector.getSelectedItem(); + Color bgColor = null; + if(selected == PUBTALK){ + bgColor = TalkDraw.COLOR_PUBLIC; + }else if(selected == WOLFTALK){ + bgColor = TalkDraw.COLOR_WOLFONLY; + }else if(selected == GRAVETALK){ + bgColor = TalkDraw.COLOR_GRAVE; + }else if(selected == PRVTALK){ + bgColor = TalkDraw.COLOR_PRIVATE; + }else if(selected == ALLTALK){ + bgColor = COLOR_ALL; + }else{ + assert false; + return null; + } + + result.setForeground(Color.BLACK); + result.setBackground(bgColor); + + return result; + } + } + +} diff --git a/src/main/java/jp/sourceforge/jindolf/DialogPref.java b/src/main/java/jp/sourceforge/jindolf/DialogPref.java new file mode 100644 index 0000000..192e639 --- /dev/null +++ b/src/main/java/jp/sourceforge/jindolf/DialogPref.java @@ -0,0 +1,128 @@ +/* + * dialog preferences + * + * Copyright(c) 2009 olyutorskii + * $Id: DialogPref.java 977 2010-01-02 15:54:12Z olyutorskii $ + */ + +package jp.sourceforge.jindolf; + +/** + * 発言表示設定。 + */ +public class DialogPref{ + + private boolean useBodyImage = false; + private boolean useMonoImage = false; + private boolean isSimpleMode = false; + private boolean alignBaloonWidth = false; + + /** + * コンストラクタ。 + */ + public DialogPref(){ + super(); + return; + } + + /** + * デカキャラモードを使うか否か状態を返す。 + * @return デカキャラモードを使うならtrue + */ + public boolean useBodyImage(){ + return this.useBodyImage; + } + + /** + * 遺影モードを使うか否か状態を返す。 + * @return 遺影モードを使うならtrue + */ + public boolean useMonoImage(){ + return this.useMonoImage; + } + + /** + * シンプル表示モードを使うか否か状態を返す。 + * @return シンプルモードならtrue + */ + public boolean isSimpleMode(){ + return this.isSimpleMode; + } + + /** + * バルーン幅揃えモードを使うか否か状態を返す。 + * @return バルーン幅揃えモードならtrue + */ + public boolean alignBaloonWidth(){ + return this.alignBaloonWidth; + } + + /** + * デカキャラモードの設定を行う。 + * @param setting 有効にするならtrue + */ + public void setBodyImageSetting(boolean setting){ + this.useBodyImage = setting; + return; + } + + /** + * 遺影モードの設定を行う。 + * @param setting 有効にするならtrue + */ + public void setMonoImageSetting(boolean setting){ + this.useMonoImage = setting; + return; + } + + /** + * シンプルモードの設定を行う。 + * @param setting 有効にするならtrue + */ + public void setSimpleMode(boolean setting){ + this.isSimpleMode = setting; + return; + } + + /** + * バルーン幅揃えの設定を行う。 + * @param setting バルーン幅を揃えたいならtrue + */ + public void setAlignBalooonWidthSetting(boolean setting){ + this.alignBaloonWidth = setting; + return; + } + + /** + * {@inheritDoc} + * @param obj {@inheritDoc} + * @return {@inheritDoc} + */ + @Override + public boolean equals(Object obj){ + if(obj instanceof DialogPref) return false; + DialogPref target = (DialogPref) obj; + + if(this.useBodyImage != target.useBodyImage) return false; + if(this.useMonoImage != target.useMonoImage) return false; + if(this.isSimpleMode != target.isSimpleMode) return false; + if(this.alignBaloonWidth != target.alignBaloonWidth) return false; + + return true; + } + + /** + * {@inheritDoc} + * @return {@inheritDoc} + */ + @Override + public int hashCode(){ + int hash; + hash = Boolean.valueOf(this.useBodyImage) .hashCode() << 0; + hash ^= Boolean.valueOf(this.useMonoImage) .hashCode() << 4; + hash ^= Boolean.valueOf(this.isSimpleMode) .hashCode() << 8; + hash ^= Boolean.valueOf(this.alignBaloonWidth).hashCode() << 12; + return hash; + } + +} diff --git a/src/main/java/jp/sourceforge/jindolf/DialogPrefPanel.java b/src/main/java/jp/sourceforge/jindolf/DialogPrefPanel.java new file mode 100644 index 0000000..de4a88c --- /dev/null +++ b/src/main/java/jp/sourceforge/jindolf/DialogPrefPanel.java @@ -0,0 +1,249 @@ +/* + * dialog preference panel + * + * Copyright(c) 2009 olyutorskii + * $Id: DialogPrefPanel.java 977 2010-01-02 15:54:12Z olyutorskii $ + */ + +package jp.sourceforge.jindolf; + +import java.awt.Container; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.awt.event.ItemEvent; +import java.awt.event.ItemListener; +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JComponent; +import javax.swing.JPanel; +import javax.swing.border.Border; + +/** + * 発言表示の各種設定パネル。 + */ +@SuppressWarnings("serial") +public class DialogPrefPanel + extends JPanel + implements ActionListener, + ItemListener { + + private final JCheckBox useBodyImage = new JCheckBox("デカキャラモード"); + private final JCheckBox useMonoImage = + new JCheckBox("墓石を遺影に置き換える"); + private final JCheckBox isSimpleMode = + new JCheckBox("シンプル表示モード"); + private final JCheckBox alignBaloon = + new JCheckBox("フキダシ幅を揃える"); + private final JButton resetDefault = new JButton("出荷時に戻す"); + + /** + * コンストラクタ。 + */ + public DialogPrefPanel(){ + this.resetDefault.addActionListener(this); + this.isSimpleMode.addItemListener(this); + + design(this); + modifyGUIState(); + + return; + } + + /** + * レイアウトを行う。 + * @param content コンテナ + */ + private void design(Container content){ + GridBagLayout layout = new GridBagLayout(); + GridBagConstraints constraints = new GridBagConstraints(); + + content.setLayout(layout); + + constraints.insets = new Insets(2, 2, 2, 2); + + constraints.weightx = 0.0; + constraints.gridwidth = GridBagConstraints.REMAINDER; + constraints.fill = GridBagConstraints.NONE; + constraints.anchor = GridBagConstraints.NORTHWEST; + + content.add(this.isSimpleMode, constraints); + content.add(this.alignBaloon, constraints); + content.add(buildIconPanel(), constraints); + + constraints.weightx = 1.0; + constraints.weighty = 1.0; + constraints.fill = GridBagConstraints.NONE; + constraints.anchor = GridBagConstraints.SOUTHEAST; + content.add(this.resetDefault, constraints); + + return; + } + + /** + * アイコン設定パネルを生成する。 + * @return アイコン設定パネル + */ + private JComponent buildIconPanel(){ + JPanel result = new JPanel(); + + GridBagLayout layout = new GridBagLayout(); + GridBagConstraints constraints = new GridBagConstraints(); + result.setLayout(layout); + + constraints.insets = new Insets(1, 1, 1, 1); + + constraints.weightx = 0.0; + constraints.gridwidth = GridBagConstraints.REMAINDER; + constraints.fill = GridBagConstraints.NONE; + constraints.anchor = GridBagConstraints.NORTHWEST; + + result.add(this.useBodyImage, constraints); + result.add(this.useMonoImage, constraints); + + Border border = BorderFactory.createTitledBorder("アイコン表示"); + result.setBorder(border); + + return result; + } + + /** + * GUI間の一貫性を維持する。 + */ + private void modifyGUIState(){ + if(this.isSimpleMode.isSelected()){ + this.useBodyImage.setEnabled(false); + this.useMonoImage.setEnabled(false); + this.alignBaloon .setEnabled(false); + }else{ + this.useBodyImage.setEnabled(true); + this.useMonoImage.setEnabled(true); + this.alignBaloon .setEnabled(true); + } + + return; + } + + /** + * デカキャラモードを使うか否か画面の状態を返す。 + * @return デカキャラモードを使うならtrue + */ + public boolean useBodyImage(){ + return this.useBodyImage.isSelected(); + } + + /** + * 遺影モードを使うか否か画面の状態を返す。 + * @return 遺影モードを使うならtrue + */ + public boolean useMonoImage(){ + return this.useMonoImage.isSelected(); + } + + /** + * シンプル表示モードか否か画面の状態を返す。 + * @return シンプル表示モードならtrue + */ + public boolean isSimpleMode(){ + return this.isSimpleMode.isSelected(); + } + + /** + * フキダシ幅を揃えるか否か画面の状態を返す。 + * @return フキダシ幅を揃えるならtrue + */ + public boolean alignBaloon(){ + return this.alignBaloon.isSelected(); + } + + /** + * デカキャラモードの設定を行う。 + * @param setting 有効にするならtrue + */ + public void setBodyImageSetting(boolean setting){ + this.useBodyImage.setSelected(setting); + return; + } + + /** + * 遺影モードの設定を行う。 + * @param setting 有効にするならtrue + */ + public void setMonoImageSetting(boolean setting){ + this.useMonoImage.setSelected(setting); + return; + } + + /** + * シンプル表示モードの設定を行う。 + * @param setting 有効にするならtrue + */ + public void setSimpleModeSetting(boolean setting){ + this.isSimpleMode.setSelected(setting); + modifyGUIState(); + return; + } + + /** + * フキダシ幅揃えの設定を行う。 + * @param setting 有効にするならtrue + */ + public void setAlignBaloonSetting(boolean setting){ + this.alignBaloon.setSelected(setting); + return; + } + + /** + * 発言表示設定を設定する。 + * @param pref 表示設定 + */ + public void setDialogPref(DialogPref pref){ + setBodyImageSetting(pref.useBodyImage()); + setMonoImageSetting(pref.useMonoImage()); + setSimpleModeSetting(pref.isSimpleMode()); + setAlignBaloonSetting(pref.alignBaloonWidth()); + modifyGUIState(); + return; + } + + /** + * 発言表示設定を返す。 + * @return 表示設定 + */ + public DialogPref getDialogPref(){ + DialogPref result = new DialogPref(); + result.setBodyImageSetting(useBodyImage()); + result.setMonoImageSetting(useMonoImage()); + result.setSimpleMode(isSimpleMode()); + result.setAlignBalooonWidthSetting(alignBaloon()); + return result; + } + + /** + * デフォルトボタン押下処理。 + * @param event ボタン押下イベント + */ + public void actionPerformed(ActionEvent event){ + Object source = event.getSource(); + if(source != this.resetDefault) return; + this.useBodyImage.setSelected(false); + this.useMonoImage.setSelected(false); + this.isSimpleMode.setSelected(false); + this.alignBaloon.setSelected(false); + modifyGUIState(); + return; + } + + /** + * チェックボックス操作の受信。 + * @param event チェックボックス操作イベント + */ + public void itemStateChanged(ItemEvent event){ + modifyGUIState(); + return; + } + +} diff --git a/src/main/java/jp/sourceforge/jindolf/Discussion.java b/src/main/java/jp/sourceforge/jindolf/Discussion.java new file mode 100644 index 0000000..9c83956 --- /dev/null +++ b/src/main/java/jp/sourceforge/jindolf/Discussion.java @@ -0,0 +1,1241 @@ +/* + * discussion viewer + * + * Copyright(c) 2008 olyutorskii + * $Id: Discussion.java 995 2010-03-15 03:54:09Z olyutorskii $ + */ + +package jp.sourceforge.jindolf; + +import java.awt.Color; +import java.awt.Component; +import java.awt.Dimension; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.Insets; +import java.awt.Point; +import java.awt.Rectangle; +import java.awt.RenderingHints; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.awt.event.ComponentEvent; +import java.awt.event.ComponentListener; +import java.awt.event.MouseEvent; +import java.awt.font.FontRenderContext; +import java.io.IOException; +import java.util.EventListener; +import java.util.LinkedList; +import java.util.List; +import java.util.ListIterator; +import java.util.regex.Pattern; +import javax.swing.AbstractAction; +import javax.swing.Action; +import javax.swing.ActionMap; +import javax.swing.InputMap; +import javax.swing.JComponent; +import javax.swing.JMenuItem; +import javax.swing.JPopupMenu; +import javax.swing.JTextField; +import javax.swing.KeyStroke; +import javax.swing.Scrollable; +import javax.swing.SwingConstants; +import javax.swing.event.EventListenerList; +import javax.swing.event.MouseInputListener; +import javax.swing.text.DefaultEditorKit; + +/** + * 発言表示画面。 + * + * 表示に影響する要因は、Periodの中身、LayoutManagerによるサイズ変更、 + * フォント属性の指定、フィルタリング操作、ドラッギングによる文字列選択操作、 + * 文字列検索および検索ナビゲーション。 + */ +@SuppressWarnings("serial") +public class Discussion extends JComponent + implements Scrollable, MouseInputListener, ComponentListener{ + + private static final Color COLOR_NORMALBG = Color.BLACK; + private static final Color COLOR_SIMPLEBG = Color.WHITE; + + private static final int MARGINTOP = 50; + private static final int MARGINBOTTOM = 100; + + private Period period; + private final List rowList = new LinkedList(); + private final List talkDrawList = new LinkedList(); + + private TopicFilter topicFilter; + private TopicFilter.FilterContext filterContext; + private RegexPattern regexPattern; + + private Point dragFrom; + + private FontInfo fontInfo; + private final RenderingHints hints = new RenderingHints(null); + + private DialogPref dialogPref; + + private Dimension idealSize; + private int lastWidth = -1; + + private final DiscussionPopup popup = new DiscussionPopup(); + + private final EventListenerList thisListenerList = + new EventListenerList(); + + private final Action copySelectedAction = + new ProxyAction(ActionManager.CMD_COPY); + + /** + * 発言表示画面を作成する。 + */ + public Discussion(){ + super(); + + this.fontInfo = FontInfo.DEFAULT_FONTINFO; + this.dialogPref = new DialogPref(); + + this.hints.put(RenderingHints.KEY_ANTIALIASING, + RenderingHints.VALUE_ANTIALIAS_ON); + this.hints.put(RenderingHints.KEY_RENDERING, + RenderingHints.VALUE_RENDER_QUALITY); + updateRenderingHints(); + + setPeriod(null); + + addMouseListener(this); + addMouseMotionListener(this); + addComponentListener(this); + + setComponentPopupMenu(this.popup); + + updateInputMap(); + ActionMap actionMap = getActionMap(); + actionMap.put(DefaultEditorKit.copyAction, this.copySelectedAction); + + setColorDesign(); + + return; + } + + /** + * 描画設定の更新。 + * FontRenderContextが更新された後は必ず呼び出す必要がある。 + */ + private void updateRenderingHints(){ + Object textAliaseValue; + FontRenderContext context = this.fontInfo.getFontRenderContext(); + if(context.isAntiAliased()){ + textAliaseValue = RenderingHints.VALUE_TEXT_ANTIALIAS_ON; + }else{ + textAliaseValue = RenderingHints.VALUE_TEXT_ANTIALIAS_OFF; + } + this.hints.put(RenderingHints.KEY_TEXT_ANTIALIASING, + textAliaseValue); + + Object textFractionalValue; + if(context.usesFractionalMetrics()){ + textFractionalValue = RenderingHints.VALUE_FRACTIONALMETRICS_ON; + }else{ + textFractionalValue = RenderingHints.VALUE_FRACTIONALMETRICS_OFF; + } + this.hints.put(RenderingHints.KEY_FRACTIONALMETRICS, + textFractionalValue); + + return; + } + + /** + * 配色を設定する。 + */ + private void setColorDesign(){ + Color fgColor; + if(this.dialogPref.isSimpleMode()){ + fgColor = COLOR_SIMPLEBG; + }else{ + fgColor = COLOR_NORMALBG; + } + + setForeground(fgColor); + repaint(); + + return; + } + + /** + * フォント描画設定を変更する。 + * @param newFontInfo フォント設定 + */ + public void setFontInfo(FontInfo newFontInfo){ + this.fontInfo = newFontInfo; + + updateRenderingHints(); + + for(TextRow row : this.rowList){ + row.setFontInfo(this.fontInfo); + } + + setColorDesign(); + layoutRows(); + + revalidate(); + repaint(); + + return; + } + + /** + * 発言表示設定を変更する。 + * @param newPref 発言表示設定 + */ + public void setDialogPref(DialogPref newPref){ + this.dialogPref = newPref; + + for(TextRow row : this.rowList){ + if(row instanceof TalkDraw){ + TalkDraw talkDraw = (TalkDraw) row; + talkDraw.setDialogPref(this.dialogPref); + }else if(row instanceof SysEventDraw){ + SysEventDraw sysDraw = (SysEventDraw) row; + sysDraw.setDialogPref(this.dialogPref); + } + } + + setColorDesign(); + layoutRows(); + + revalidate(); + repaint(); + + return; + } + + /** + * 現在のPeriodを返す。 + * @return 現在のPeriod + */ + public Period getPeriod(){ + return this.period; + } + + /** + * Periodを更新する。 + * 新しいPeriodの表示内容はまだ反映されない。 + * @param period 新しいPeriod + */ + public final void setPeriod(Period period){ + if(period == null){ + this.period = null; + this.rowList.clear(); + this.talkDrawList.clear(); + return; + } + + if( this.period == period + && period.getTopics() == this.rowList.size() ){ + filterTopics(); + return; + } + + this.period = period; + + this.filterContext = null; + + this.rowList.clear(); + this.talkDrawList.clear(); + for(Topic topic : this.period.getTopicList()){ + TextRow row; + if(topic instanceof Talk){ + Talk talk = (Talk) topic; + TalkDraw talkDraw = new TalkDraw(talk, + this.dialogPref, + this.fontInfo ); + this.talkDrawList.add(talkDraw); + row = talkDraw; + }else if(topic instanceof SysEvent){ + SysEvent sysEvent = (SysEvent) topic; + row = new SysEventDraw(sysEvent, + this.dialogPref, + this.fontInfo ); + }else{ + assert false; + continue; + } + this.rowList.add(row); + } + + filterTopics(); + + clearSizeCache(); + + layoutRows(); + + return; + } + + /** + * 発言フィルタを設定する。 + * @param filter 発言フィルタ + */ + public void setTopicFilter(TopicFilter filter){ + this.topicFilter = filter; + filtering(); + return; + } + + /** + * 発言フィルタを適用する。 + */ + public void filtering(){ + if( this.topicFilter != null + && this.topicFilter.isSame(this.filterContext)){ + return; + } + + if(this.topicFilter != null){ + this.filterContext = this.topicFilter.getFilterContext(); + }else{ + this.filterContext = null; + } + + filterTopics(); + layoutVertical(); + + clearSelect(); + + return; + } + + /** + * 検索パターンを取得する。 + * @return 検索パターン + */ + public RegexPattern getRegexPattern(){ + return this.regexPattern; + } + + /** + * 与えられた正規表現にマッチする文字列をハイライト描画する。 + * @param newPattern 検索パターン + * @return ヒット件数 + */ + public int setRegexPattern(RegexPattern newPattern){ + this.regexPattern = newPattern; + + int total = 0; + + clearHotTarget(); + + Pattern pattern = null; + if(this.regexPattern != null){ + pattern = this.regexPattern.getPattern(); + } + + for(TalkDraw talkDraw : this.talkDrawList){ + total += talkDraw.setRegex(pattern); + } + + repaint(); + + return total; + } + + /** + * 検索結果の次候補をハイライト表示する。 + */ + public void nextHotTarget(){ + TalkDraw oldTalk = null; + int oldIndex = -1; + TalkDraw newTalk = null; + int newIndex = -1; + TalkDraw firstTalk = null; + + boolean findOld = true; + for(TalkDraw talkDraw : this.talkDrawList){ + int matches = talkDraw.getRegexMatches(); + if(firstTalk == null && matches > 0){ + firstTalk = talkDraw; + } + if(findOld){ + int index = talkDraw.getHotTargetIndex(); + if(index < 0) continue; + oldTalk = talkDraw; + oldIndex = index; + scrollRectWithMargin(talkDraw.getHotTargetRectangle()); + if(oldIndex < matches - 1 && ! isFiltered(talkDraw) ){ + newTalk = talkDraw; + newIndex = oldIndex + 1; + break; + } + findOld = false; + }else{ + if(isFiltered(talkDraw)) continue; + if(matches <= 0) continue; + newTalk = talkDraw; + newIndex = 0; + break; + } + } + + Rectangle showRect = null; + if(oldTalk == null && firstTalk != null){ + firstTalk.setHotTargetIndex(0); + showRect = firstTalk.getHotTargetRectangle(); + }else if( oldTalk != null + && newTalk != null){ + oldTalk.clearHotTarget(); + newTalk.setHotTargetIndex(newIndex); + showRect = newTalk.getHotTargetRectangle(); + } + + if(showRect != null){ + scrollRectWithMargin(showRect); + } + + repaint(); + + return; + } + + /** + * 検索結果の前候補をハイライト表示する。 + */ + public void prevHotTarget(){ + TalkDraw oldTalk = null; + int oldIndex = -1; + TalkDraw newTalk = null; + int newIndex = -1; + TalkDraw firstTalk = null; + + boolean findOld = true; + int size = this.talkDrawList.size(); + ListIterator iterator = + this.talkDrawList.listIterator(size); + while(iterator.hasPrevious()){ + TalkDraw talkDraw = iterator.previous(); + int matches = talkDraw.getRegexMatches(); + if(firstTalk == null && matches > 0){ + firstTalk = talkDraw; + } + if(findOld){ + int index = talkDraw.getHotTargetIndex(); + if(index < 0) continue; + oldTalk = talkDraw; + oldIndex = index; + scrollRectWithMargin(talkDraw.getHotTargetRectangle()); + if(oldIndex > 0 && ! isFiltered(talkDraw) ){ + newTalk = talkDraw; + newIndex = oldIndex - 1; + break; + } + findOld = false; + }else{ + if(isFiltered(talkDraw)) continue; + if(matches <= 0) continue; + newTalk = talkDraw; + newIndex = matches - 1; + break; + } + } + + Rectangle showRect = null; + if(oldTalk == null && firstTalk != null){ + int matches = firstTalk.getRegexMatches(); + firstTalk.setHotTargetIndex(matches - 1); + showRect = firstTalk.getHotTargetRectangle(); + }else if( oldTalk != null + && newTalk != null){ + oldTalk.clearHotTarget(); + newTalk.setHotTargetIndex(newIndex); + showRect = newTalk.getHotTargetRectangle(); + } + + if(showRect != null){ + scrollRectWithMargin(showRect); + } + + repaint(); + + return; + } + + /** + * 検索結果の特殊ハイライト表示を解除。 + */ + public void clearHotTarget(){ + for(TalkDraw talkDraw : this.talkDrawList){ + talkDraw.clearHotTarget(); + } + repaint(); + return; + } + + /** + * 指定した領域に若干の上下マージンを付けて + * スクロールウィンドウに表示させる。 + * @param rectangle 指定領域 + */ + private void scrollRectWithMargin(Rectangle rectangle){ + Rectangle show = new Rectangle(rectangle); + show.y -= MARGINTOP; + show.height += MARGINTOP + MARGINBOTTOM; + + scrollRectToVisible(show); + + return; + } + + /** + * 過去に計算した寸法を破棄する。 + */ + private void clearSizeCache(){ + this.idealSize = null; + this.lastWidth = -1; + revalidate(); + return; + } + + /** + * 指定した矩形がフィルタリング対象か判定する。 + * @param row 矩形 + * @return フィルタリング対象ならtrue + */ + private boolean isFiltered(TextRow row){ + if(this.topicFilter == null) return false; + + Topic topic; + if(row instanceof TalkDraw){ + topic = ((TalkDraw)row).getTalk(); + }else if(row instanceof SysEventDraw){ + topic = ((SysEventDraw)row).getSysEvent(); + }else{ + return false; + } + + return this.topicFilter.isFiltered(topic); + } + + /** + * フィルタリング指定に従いTextRowを表示するか否か設定する。 + */ + private void filterTopics(){ + for(TextRow row : this.rowList){ + if(isFiltered(row)) row.setVisible(false); + else row.setVisible(true); + } + return; + } + + /** + * 幅を設定する。 + * 全子TextRowがリサイズされる。 + * @param width コンポーネント幅 + */ + private void setWidth(int width){ + this.lastWidth = width; + Insets insets = getInsets(); + int rowWidth = width - (insets.left + insets.right); + for(TextRow row : this.rowList){ + row.setWidth(rowWidth); + } + + layoutVertical(); + + return; + } + + /** + * 子TextRowの縦位置レイアウトを行う。 + * フィルタリングが反映される。 + * TextRowは必要に応じて移動させられるがリサイズされることはない。 + */ + private void layoutVertical(){ + Rectangle unionRect = null; + Insets insets = getInsets(); + int vertPos = insets.top; + + for(TextRow row : this.rowList){ + if( ! row.isVisible() ) continue; + + row.setPos(insets.left, vertPos); + Rectangle rowBound = row.getBounds(); + vertPos += rowBound.height; + + if(unionRect == null){ + unionRect = new Rectangle(rowBound); + }else{ + unionRect.add(rowBound); + } + } + + if(unionRect == null){ + unionRect = new Rectangle(insets.left, insets.top, 0, 0); + } + + if(this.idealSize == null){ + this.idealSize = new Dimension(); + } + + int newWidth = insets.left + unionRect.width + insets.right; + int newHeight = insets.top + unionRect.height + insets.bottom; + + this.idealSize.setSize(newWidth, newHeight); + + setPreferredSize(this.idealSize); + + revalidate(); + repaint(); + + return; + } + + /** + * Rowsの縦位置を再レイアウトする。 + */ + public void layoutRows(){ + int width = getWidth(); + setWidth(width); + return; + } + + /** + * {@inheritDoc} + * @param g {@inheritDoc} + */ + @Override + public void paintComponent(Graphics g){ + Graphics2D g2 = (Graphics2D) g; + g2.setRenderingHints(this.hints); + + Rectangle clipRect = g2.getClipBounds(); + g2.fillRect(clipRect.x, clipRect.y, clipRect.width, clipRect.height); + + for(TextRow row : this.rowList){ + if( ! row.isVisible() ) continue; + + Rectangle rowRect = row.getBounds(); + if( ! rowRect.intersects(clipRect) ) continue; + + row.paint(g2); + } + + return; + } + + /** + * {@inheritDoc} + * @return {@inheritDoc} + */ + public Dimension getPreferredScrollableViewportSize(){ + return getPreferredSize(); + } + + /** + * {@inheritDoc} + * @return {@inheritDoc} + */ + public boolean getScrollableTracksViewportWidth(){ + return true; + } + + /** + * {@inheritDoc} + * @return {@inheritDoc} + */ + public boolean getScrollableTracksViewportHeight(){ + return false; + } + + /** + * {@inheritDoc} + * @param visibleRect {@inheritDoc} + * @param orientation {@inheritDoc} + * @param direction {@inheritDoc} + * @return {@inheritDoc} + */ + public int getScrollableBlockIncrement(Rectangle visibleRect, + int orientation, + int direction ){ + if(orientation == SwingConstants.VERTICAL){ + return visibleRect.height; + } + return 30; // TODO フォント高 × 1.5 ぐらい? + } + + /** + * {@inheritDoc} + * @param visibleRect {@inheritDoc} + * @param orientation {@inheritDoc} + * @param direction {@inheritDoc} + * @return {@inheritDoc} + */ + public int getScrollableUnitIncrement(Rectangle visibleRect, + int orientation, + int direction ){ + return 30; + } + + /** + * 任意の発言の表示が占める画面領域を返す。 + * 発言がフィルタリング対象の時はnullを返す。 + * @param talk 発言 + * @return 領域 + */ + public Rectangle getTalkBounds(Talk talk){ + if( this.topicFilter != null + && this.topicFilter.isFiltered(talk)) return null; + + for(TalkDraw talkDraw : this.talkDrawList){ + if(talkDraw.getTalk() == talk){ + Rectangle rect = talkDraw.getBounds(); + return rect; + } + } + + return null; + } + + /** + * ドラッグ処理を行う。 + * @param from ドラッグ開始位置 + * @param to 現在のドラッグ位置 + */ + private void drag(Point from, Point to){ + Rectangle dragRegion = new Rectangle(); + dragRegion.setFrameFromDiagonal(from, to); + + for(TextRow row : this.rowList){ + if(isFiltered(row)) continue; + if( ! row.getBounds().intersects(dragRegion) ) continue; + row.drag(from, to); + } + repaint(); + return; + } + + /** + * 選択範囲の解除。 + */ + private void clearSelect(){ + for(TextRow row : this.rowList){ + row.clearSelect(); + } + repaint(); + return; + } + + /** + * 与えられた点座標を包含する発言を返す。 + * @param pt 点座標(JComponent基準) + * @return 点座標を含む発言。含む発言がなければnullを返す。 + */ + // TODO 二分探索とかしたい。 + private TalkDraw getHittedTalkDraw(Point pt){ + for(TalkDraw talkDraw : this.talkDrawList){ + if(isFiltered(talkDraw)) continue; + Rectangle bounds = talkDraw.getBounds(); + if(bounds.contains(pt)) return talkDraw; + } + return null; + } + + /** + * アンカークリック動作の処理。 + * @param pt クリックポイント + */ + private void hitAnchor(Point pt){ + TalkDraw talkDraw = getHittedTalkDraw(pt); + if(talkDraw == null) return; + + Anchor anchor = talkDraw.getAnchor(pt); + if(anchor == null) return; + + for(AnchorHitListener listener : getAnchorHitListeners()){ + AnchorHitEvent event = + new AnchorHitEvent(this, talkDraw, anchor, pt); + listener.anchorHitted(event); + } + + return; + } + + /** + * 検索マッチ文字列クリック動作の処理。 + * @param pt クリックポイント + */ + private void hitRegex(Point pt){ + TalkDraw talkDraw = getHittedTalkDraw(pt); + if(talkDraw == null) return; + + int index = talkDraw.getRegexMatchIndex(pt); + if(index < 0) return; + + clearHotTarget(); + talkDraw.setHotTargetIndex(index); + + return; + } + + /** + * {@inheritDoc} + * アンカーヒット処理を行う。 + * MouseInputListenerを参照せよ。 + * @param event {@inheritDoc} + */ + // TODO 距離判定がシビアすぎ + public void mouseClicked(MouseEvent event){ + Point pt = event.getPoint(); + if(event.getButton() == MouseEvent.BUTTON1){ + clearSelect(); + hitAnchor(pt); + hitRegex(pt); + } + return; + } + + /** + * {@inheritDoc} + * @param event {@inheritDoc} + */ + public void mouseEntered(MouseEvent event){ + // TODO ここでキーボードフォーカス処理が必要? + return; + } + + /** + * {@inheritDoc} + * @param event {@inheritDoc} + */ + public void mouseExited(MouseEvent event){ + return; + } + + /** + * {@inheritDoc} + * ドラッグ開始処理を行う。 + * @param event {@inheritDoc} + */ + public void mousePressed(MouseEvent event){ + requestFocusInWindow(); + + if(event.getButton() == MouseEvent.BUTTON1){ + clearSelect(); + this.dragFrom = event.getPoint(); + } + + return; + } + + /** + * {@inheritDoc} + * ドラッグ終了処理を行う。 + * @param event {@inheritDoc} + */ + public void mouseReleased(MouseEvent event){ + if(event.getButton() == MouseEvent.BUTTON1){ + this.dragFrom = null; + } + return; + } + + /** + * {@inheritDoc} + * ドラッグ処理を行う。 + * @param event {@inheritDoc} + */ + // TODO ドラッグ範囲がビューポートを超えたら自動的にスクロールしてほしい。 + public void mouseDragged(MouseEvent event){ + if(this.dragFrom == null) return; + Point dragTo = event.getPoint(); + drag(this.dragFrom, dragTo); + return; + } + + /** + * {@inheritDoc} + * @param event {@inheritDoc} + */ + public void mouseMoved(MouseEvent event){ + return; + } + + /** + * {@inheritDoc} + * @param event {@inheritDoc} + */ + public void componentShown(ComponentEvent event){ + return; + } + + /** + * {@inheritDoc} + * @param event {@inheritDoc} + */ + public void componentHidden(ComponentEvent event){ + return; + } + + /** + * {@inheritDoc} + * @param event {@inheritDoc} + */ + public void componentMoved(ComponentEvent event){ + return; + } + + /** + * {@inheritDoc} + * @param event {@inheritDoc} + */ + public void componentResized(ComponentEvent event){ + int width = getWidth(); + int height = getHeight(); + if(width != this.lastWidth){ + setWidth(width); + } + if( this.idealSize.width != width + || this.idealSize.height != height ){ + revalidate(); + } + return; + } + + /** + * 選択文字列を返す。 + * @return 選択文字列 + */ + public CharSequence getSelected(){ + StringBuilder selected = new StringBuilder(); + + for(TextRow row : this.rowList){ + if(isFiltered(row)) continue; + try{ + row.appendSelected(selected); + }catch(IOException e){ + assert false; // ありえない + return null; + } + } + + if(selected.length() <= 0) return null; + + return selected; + } + + /** + * 選択文字列をクリップボードにコピーする。 + * @return 選択文字列 + */ + public CharSequence copySelected(){ + CharSequence selected = getSelected(); + if(selected == null) return null; + ClipboardAction.copyToClipboard(selected); + return selected; + } + + /** + * 矩形の示す一発言をクリップボードにコピーする。 + * @return コピーした文字列 + */ + public CharSequence copyTalk(){ + TalkDraw talkDraw = this.popup.lastPopupedTalkDraw; + if(talkDraw == null) return null; + Talk talk = talkDraw.getTalk(); + + StringBuilder selected = new StringBuilder(); + + Avatar avatar = talk.getAvatar(); + selected.append(avatar.getName()).append(' '); + + String anchor = talk.getAnchorNotation(); + selected.append(anchor); + if(talk.hasTalkNo()){ + selected.append(' ').append(talk.getAnchorNotation_G()); + } + selected.append('\n'); + + selected.append(talk.getDialog()); + if(selected.charAt(selected.length() - 1) != '\n'){ + selected.append('\n'); + } + + ClipboardAction.copyToClipboard(selected); + + return selected; + } + + /** + * ポップアップメニュートリガ座標に発言があればそれを返す。 + * @return 発言 + */ + public Talk getPopupedTalk(){ + TalkDraw talkDraw = this.popup.lastPopupedTalkDraw; + if(talkDraw == null) return null; + Talk talk = talkDraw.getTalk(); + return talk; + } + + /** + * ポップアップメニュートリガ座標にアンカーがあればそれを返す。 + * @return アンカー + */ + public Anchor getPopupedAnchor(){ + return this.popup.lastPopupedAnchor; + } + + /** + * {@inheritDoc} + */ + @Override + public void updateUI(){ + super.updateUI(); + this.popup.updateUI(); + + updateInputMap(); + + return; + } + + /** + * COPY処理を行うキーの設定をJTextFieldから流用する。 + * おそらくはCtrl-C。MacならCommand-Cかも。 + */ + private void updateInputMap(){ + InputMap thisInputMap = getInputMap(); + + InputMap sampleInputMap; + sampleInputMap = new JTextField().getInputMap(); + KeyStroke[] strokes = sampleInputMap.allKeys(); + for(KeyStroke stroke : strokes){ + Object bind = sampleInputMap.get(stroke); + if(bind.equals(DefaultEditorKit.copyAction)){ + thisInputMap.put(stroke, DefaultEditorKit.copyAction); + } + } + + return; + } + + /** + * ActionListenerを追加する。 + * @param listener リスナー + */ + public void addActionListener(ActionListener listener){ + this.thisListenerList.add(ActionListener.class, listener); + + this.popup.menuCopy .addActionListener(listener); + this.popup.menuSelTalk .addActionListener(listener); + this.popup.menuJumpAnchor .addActionListener(listener); + this.popup.menuWebTalk .addActionListener(listener); + this.popup.menuSummary .addActionListener(listener); + + return; + } + + /** + * ActionListenerを削除する。 + * @param listener リスナー + */ + public void removeActionListener(ActionListener listener){ + this.thisListenerList.remove(ActionListener.class, listener); + + this.popup.menuCopy .removeActionListener(listener); + this.popup.menuSelTalk .removeActionListener(listener); + this.popup.menuJumpAnchor .removeActionListener(listener); + this.popup.menuWebTalk .removeActionListener(listener); + this.popup.menuSummary .removeActionListener(listener); + + return; + } + + /** + * ActionListenerを列挙する。 + * @return すべてのActionListener + */ + public ActionListener[] getActionListeners(){ + return this.thisListenerList.getListeners(ActionListener.class); + } + + /** + * AnchorHitListenerを追加する。 + * @param listener リスナー + */ + public void addAnchorHitListener(AnchorHitListener listener){ + this.thisListenerList.add(AnchorHitListener.class, listener); + return; + } + + /** + * AnchorHitListenerを削除する。 + * @param listener リスナー + */ + public void removeAnchorHitListener(AnchorHitListener listener){ + this.thisListenerList.remove(AnchorHitListener.class, listener); + return; + } + + /** + * AnchorHitListenerを列挙する。 + * @return すべてのAnchorHitListener + */ + public AnchorHitListener[] getAnchorHitListeners(){ + return this.thisListenerList.getListeners(AnchorHitListener.class); + } + + /** + * {@inheritDoc} + * @param {@inheritDoc} + * @param listenerType {@inheritDoc} + * @return {@inheritDoc} + */ + @Override + public T[] getListeners(Class listenerType){ + T[] result; + result = this.thisListenerList.getListeners(listenerType); + + if(result.length <= 0){ + result = super.getListeners(listenerType); + } + + return result; + } + + /** + * キーボード入力用ダミーAction。 + */ + private class ProxyAction extends AbstractAction{ + + private final String command; + + /** + * コンストラクタ。 + * @param command コマンド + * @throws NullPointerException 引数がnull + */ + public ProxyAction(String command) throws NullPointerException{ + super(); + if(command == null) throw new NullPointerException(); + this.command = command; + return; + } + + /** + * {@inheritDoc} + * @param event {@inheritDoc} + */ + public void actionPerformed(ActionEvent event){ + Object source = event.getSource(); + int id = event.getID(); + String actcmd = this.command; + long when = event.getWhen(); + int modifiers = event.getModifiers(); + + for(ActionListener listener : getActionListeners()){ + ActionEvent newEvent = new ActionEvent(source, + id, + actcmd, + when, + modifiers ); + listener.actionPerformed(newEvent); + } + + return; + } + }; + + /** + * ポップアップメニュー。 + */ + private class DiscussionPopup extends JPopupMenu{ + + private final JMenuItem menuCopy = + new JMenuItem("選択範囲をコピー"); + private final JMenuItem menuSelTalk = + new JMenuItem("この発言をコピー"); + private final JMenuItem menuJumpAnchor = + new JMenuItem("アンカーの示す先へジャンプ"); + private final JMenuItem menuWebTalk = + new JMenuItem("この発言をブラウザで表示..."); + private final JMenuItem menuSummary = + new JMenuItem("発言を集計..."); + + private TalkDraw lastPopupedTalkDraw; + private Anchor lastPopupedAnchor; + + /** + * コンストラクタ。 + */ + public DiscussionPopup(){ + super(); + + add(this.menuCopy); + add(this.menuSelTalk); + addSeparator(); + add(this.menuJumpAnchor); + add(this.menuWebTalk); + addSeparator(); + add(this.menuSummary); + + this.menuCopy + .setActionCommand(ActionManager.CMD_COPY); + this.menuSelTalk + .setActionCommand(ActionManager.CMD_COPYTALK); + this.menuJumpAnchor + .setActionCommand(ActionManager.CMD_JUMPANCHOR); + this.menuWebTalk + .setActionCommand(ActionManager.CMD_WEBTALK); + this.menuSummary + .setActionCommand(ActionManager.CMD_DAYSUMMARY); + + this.menuWebTalk.setIcon(GUIUtils.getWWWIcon()); + + return; + } + + /** + * {@inheritDoc} + * @param comp {@inheritDoc} + * @param x {@inheritDoc} + * @param y {@inheritDoc} + */ + @Override + public void show(Component comp, int x, int y){ + Point point = new Point(x, y); + + this.lastPopupedTalkDraw = getHittedTalkDraw(point); + if(this.lastPopupedTalkDraw != null){ + this.menuSelTalk.setEnabled(true); + this.menuWebTalk.setEnabled(true); + }else{ + this.menuSelTalk.setEnabled(false); + this.menuWebTalk.setEnabled(false); + } + + if(this.lastPopupedTalkDraw != null){ + this.lastPopupedAnchor = + this.lastPopupedTalkDraw.getAnchor(point); + }else{ + this.lastPopupedAnchor = null; + } + + if(this.lastPopupedAnchor != null){ + this.menuJumpAnchor.setEnabled(true); + }else{ + this.menuJumpAnchor.setEnabled(false); + } + + if(getSelected() != null){ + this.menuCopy.setEnabled(true); + }else{ + this.menuCopy.setEnabled(false); + } + + super.show(comp, x, y); + + return; + } + } + + // TODO シンプルモードの追加 + // Period変更を追跡するリスナ化 +} diff --git a/src/main/java/jp/sourceforge/jindolf/EditArray.java b/src/main/java/jp/sourceforge/jindolf/EditArray.java new file mode 100644 index 0000000..c018704 --- /dev/null +++ b/src/main/java/jp/sourceforge/jindolf/EditArray.java @@ -0,0 +1,730 @@ +/* + * エディタ集合の操作 + * + * Copyright(c) 2008 olyutorskii + * $Id: EditArray.java 953 2009-12-06 16:42:14Z olyutorskii $ + */ + +package jp.sourceforge.jindolf; + +import java.awt.Dimension; +import java.awt.EventQueue; +import java.awt.Font; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.LayoutManager; +import java.awt.Rectangle; +import java.awt.event.FocusEvent; +import java.awt.event.FocusListener; +import java.util.ArrayList; +import java.util.List; +import javax.swing.JPanel; +import javax.swing.Scrollable; +import javax.swing.SwingConstants; +import javax.swing.event.ChangeEvent; +import javax.swing.event.ChangeListener; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import javax.swing.text.BadLocationException; +import javax.swing.text.Document; +import javax.swing.text.JTextComponent; +import javax.swing.text.NavigationFilter; +import javax.swing.text.Position.Bias; + +/** + * エディタ集合の操作。 + * ※ このクラスはすべてシングルスレッドモデルで作られている。 + */ +@SuppressWarnings("serial") +public class EditArray extends JPanel + implements Scrollable, + FocusListener { + + private static final int MAX_EDITORS = 50; + + private final List editorList = new ArrayList(); + private boolean onAdjusting = false; + + private final NavigationFilter keyNavigator = new CustomNavigation(); + private final DocumentListener documentListener = new DocWatcher(); + + private TalkEditor activeEditor; + + private Font textFont; + + /** + * コンストラクタ。 + */ + public EditArray(){ + super(); + + setOpaque(false); + + LayoutManager layout = new GridBagLayout(); + setLayout(layout); + + TalkEditor firstEditor = incrementTalkEditor(); + setActiveEditor(firstEditor); + + return; + } + + /** + * 個別エディタの生成を行う。 + * @return エディタ + */ + private TalkEditor createTalkEditor(){ + TalkEditor editor = new TalkEditor(); + editor.setNavigationFilter(this.keyNavigator); + editor.addTextFocusListener(this); + Document document = editor.getDocument(); + document.addDocumentListener(this.documentListener); + + if(this.textFont == null){ + this.textFont = editor.getTextFont(); + }else{ + editor.setTextFont(this.textFont); + } + + return editor; + } + + /** + * エディタ集合を一つ増やす。 + * @return 増えたエディタ + */ + private TalkEditor incrementTalkEditor(){ + TalkEditor editor = createTalkEditor(); + + GridBagConstraints constraints = new GridBagConstraints(); + + constraints.gridx = 0; + constraints.gridy = GridBagConstraints.RELATIVE; + + constraints.gridwidth = GridBagConstraints.REMAINDER; + constraints.gridheight = 1; + + constraints.weightx = 1.0; + constraints.weighty = 0.0; + + constraints.fill = GridBagConstraints.HORIZONTAL; + constraints.anchor = GridBagConstraints.NORTHEAST; + + add(editor, constraints); + + this.editorList.add(editor); + + int sequenceNumber = this.editorList.size(); + editor.setSequenceNumber(sequenceNumber); + + return editor; + } + + /** + * 1から始まる通し番号指定でエディタを取得する。 + * 存在しない通し番号が指定された場合は新たにエディタが追加される。 + * @param sequenceNumber 通し番号 + * @return エディタ + */ + private TalkEditor getTalkEditor(int sequenceNumber){ + while(this.editorList.size() < sequenceNumber){ + incrementTalkEditor(); + } + + TalkEditor result = this.editorList.get(sequenceNumber - 1); + + return result; + } + + /** + * 指定したエディタの次の通し番号を持つエディタを返す。 + * エディタがなければ追加される。 + * @param editor エディタ + * @return 次のエディタ + */ + private TalkEditor nextEditor(TalkEditor editor){ + int sequenceNumber = editor.getSequenceNumber(); + TalkEditor nextEditor = getTalkEditor(sequenceNumber + 1); + return nextEditor; + } + + /** + * 指定したエディタの前の通し番号を持つエディタを返す。 + * @param editor エディタ + * @return 前のエディタ。 + * 最初のエディタ(通し番号1)が指定されればnullを返す。 + */ + private TalkEditor prevEditor(TalkEditor editor){ + int sequenceNumber = editor.getSequenceNumber(); + if(sequenceNumber <= 1) return null; + TalkEditor prevEditor = getTalkEditor(sequenceNumber - 1); + return prevEditor; + } + + /** + * 指定したエディタがエディタ集合の最後のエディタか判定する。 + * @param editor エディタ + * @return 最後のエディタならtrue + */ + private boolean isLastEditor(TalkEditor editor){ + int seqNo = editor.getSequenceNumber(); + int size = this.editorList.size(); + if(seqNo >= size) return true; + return false; + } + + /** + * Documentからその持ち主であるエディタを取得する。 + * @param document Documentインスタンス + * @return 持ち主のエディタ。見つからなければnull。 + */ + private TalkEditor getEditorFromDocument(Document document){ + for(TalkEditor editor : this.editorList){ + if(editor.getDocument() == document) return editor; + } + return null; + } + + /** + * エディタ集合から任意のエディタを除く。 + * ただし最初のエディタは消去不可。 + * @param editor エディタ + */ + private void removeEditor(TalkEditor editor){ + if(editor.getParent() != this) return; + + int seqNo = editor.getSequenceNumber(); + if(seqNo <= 1) return; + TalkEditor prevEditor = prevEditor(editor); + if(editor.isActive()){ + setActiveEditor(prevEditor); + } + if(editor.hasEditorFocus()){ + prevEditor.requestEditorFocus(); + } + + this.editorList.remove(seqNo - 1); + + editor.setNavigationFilter(null); + editor.removeTextFocusListener(this); + Document document = editor.getDocument(); + document.removeDocumentListener(this.documentListener); + editor.clearText(); + + remove(editor); + revalidate(); + + int renumber = 1; + for(TalkEditor newEditor : this.editorList){ + newEditor.setSequenceNumber(renumber++); + } + + return; + } + + /** + * エディタ間文字調整タスクをディスパッチスレッドとして事後投入する。 + * エディタ間文字調整タスクが実行中であれば何もしない。 + * きっかけとなったエディタ上でIME操作が確定していなければ何もしない。 + * @param triggerEvent ドキュメント変更イベント + */ + private void detachAdjustTask(DocumentEvent triggerEvent){ + if(this.onAdjusting) return; + + Document document = triggerEvent.getDocument(); + final TalkEditor triggerEditor = getEditorFromDocument(document); + if(triggerEditor.onIMEoperation()) return; + + this.onAdjusting = true; + + EventQueue.invokeLater(new Runnable(){ + public void run(){ + try{ + adjustTask(triggerEditor); + }finally{ + EditArray.this.onAdjusting = false; + } + return; + } + }); + + return; + } + + /** + * エディタ間文字調整タスク本体。 + * @param triggerEditor タスク実行のきっかけとなったエディタ + */ + private void adjustTask(TalkEditor triggerEditor){ + int initCaretPos = triggerEditor.getCaretPosition(); + + TalkEditor newFocus = null; + int newCaretPos = -1; + + TalkEditor current = triggerEditor; + for(;;){ + TalkEditor next; + + if( ! isLastEditor(current) ){ + next = nextEditor(current); + String nextContents = next.getText(); + int nextLength = nextContents.length(); + + current.appendTail(nextContents); + String rest = current.chopRest(); + int restLength; + if(rest == null) restLength = 0; + else restLength = rest.length(); + + int chopLength = nextLength - restLength; + if(chopLength > 0){ + next.chopHead(chopLength); + }else if(chopLength < 0){ + rest = rest.substring(0, -chopLength); + next.appendHead(rest); + }else{ + if(newFocus == null){ + newFocus = current; + newCaretPos = initCaretPos; + } + break; + } + }else{ + String rest = current.chopRest(); + if(rest == null || this.editorList.size() >= MAX_EDITORS){ + if(newFocus == null){ + newFocus = current; + if(current.getTextLength() >= initCaretPos){ + newCaretPos = initCaretPos; + }else{ + newCaretPos = current.getTextLength(); + } + } + break; + } + next = nextEditor(current); + next.appendHead(rest); + } + + if(newFocus == null){ + int currentLength = current.getTextLength(); + if(initCaretPos >= currentLength){ + initCaretPos -= currentLength; + }else{ + newFocus = current; + newCaretPos = initCaretPos; + } + } + + current = next; + } + + if(newFocus != null){ + newFocus.requestEditorFocus(); + newFocus.setCaretPosition(newCaretPos); + } + + adjustEditorsTail(); + + return; + } + + /** + * エディタ集合末尾の空エディタを切り詰める。 + * ただし最初のエディタ(通し番号1)は削除されない。 + * フォーカスを持つエディタが削除された場合は、 + * 削除されなかった最後のエディタにフォーカスが移る。 + */ + private void adjustEditorsTail(){ + int editorNum = this.editorList.size(); + if(editorNum <= 0) return; + TalkEditor lastEditor = this.editorList.get(editorNum - 1); + + TalkEditor prevlostEditor = null; + + boolean lostFocusedEditor = false; + + for(;;){ + int textLength = lastEditor.getTextLength(); + int seqNo = lastEditor.getSequenceNumber(); + + if(lostFocusedEditor){ + prevlostEditor = lastEditor; + } + + if(textLength > 0) break; + if(seqNo <= 1) break; + + if(lastEditor.hasEditorFocus()) lostFocusedEditor = true; + removeEditor(lastEditor); + + lastEditor = prevEditor(lastEditor); // TODO ちょっと変 + } + + if(prevlostEditor != null){ + int textLength = prevlostEditor.getTextLength(); + prevlostEditor.requestEditorFocus(); + prevlostEditor.setCaretPosition(textLength); + } + + return; + } + + /** + * フォーカスを持つエディタを取得する。 + * @return エディタ + */ + public TalkEditor getFocusedTalkEditor(){ + for(TalkEditor editor : this.editorList){ + if(editor.hasEditorFocus()) return editor; + } + return null; + } + + /** + * フォーカスを持つエディタの次エディタがあればフォーカスを移し、 + * カレット位置を0にする。 + */ + // TODO エディタのスクロール位置調整が必要。 + public void forwardEditor(){ + TalkEditor editor = getFocusedTalkEditor(); + if(isLastEditor(editor)) return; + TalkEditor next = nextEditor(editor); + next.setCaretPosition(0); + next.requestEditorFocus(); + return; + } + + /** + * フォーカスを持つエディタの前エディタがあればフォーカスを移し、 + * カレット位置を末尾に置く。 + */ + public void backwardEditor(){ + TalkEditor editor = getFocusedTalkEditor(); + TalkEditor prev = prevEditor(editor); + if(prev == null) return; + int length = prev.getTextLength(); + prev.setCaretPosition(length); + prev.requestEditorFocus(); + return; + } + + /** + * 任意のエディタをアクティブにする。 + * 同時にアクティブなエディタは一つのみ。 + * @param editor アクティブにするエディタ + */ + private void setActiveEditor(TalkEditor editor){ + if(this.activeEditor != null){ + this.activeEditor.setActive(false); + } + + this.activeEditor = editor; + + if(this.activeEditor != null){ + this.activeEditor.setActive(true); + } + + fireChangeActive(); + + return; + } + + /** + * アクティブなエディタを返す。 + * @return アクティブなエディタ。 + */ + public TalkEditor getActiveEditor(){ + return this.activeEditor; + } + + /** + * 全発言を連結した文字列を返す。 + * @return 連結文字列 + */ + public CharSequence getAllText(){ + StringBuilder result = new StringBuilder(); + + for(TalkEditor editor : this.editorList){ + String text = editor.getText(); + result.append(text); + } + + return result; + } + + /** + * 先頭エディタの0文字目から字を詰め込む。 + * 2番目移行のエディタへはみ出すかもしれない。 + * @param seq 詰め込む文字列 + */ + public void setAllText(CharSequence seq){ + TalkEditor firstEditor = getTalkEditor(1); + Document doc = firstEditor.getDocument(); + try{ + doc.insertString(0, seq.toString(), null); + }catch(BadLocationException e){ + assert false; + } + return; + } + + /** + * 全エディタをクリアする。 + */ + public void clearAllEditor(){ + int editorNum = this.editorList.size(); + if(editorNum <= 0) return; + + TalkEditor lastEditor = this.editorList.get(editorNum - 1); + for(;;){ + removeEditor(lastEditor); + lastEditor = prevEditor(lastEditor); + if(lastEditor == null) break; + } + + TalkEditor firstEditor = getTalkEditor(1); + firstEditor.clearText(); + setActiveEditor(firstEditor); + + return; + } + + /** + * テキスト編集用フォントを指定する。 + * @param textFont フォント + */ + public void setTextFont(Font textFont){ + this.textFont = textFont; + for(TalkEditor editor : this.editorList){ + editor.setTextFont(this.textFont); + editor.repaint(); + } + revalidate(); + return; + } + + /** + * テキスト編集用フォントを取得する。 + * @return フォント + */ + public Font getTextFont(){ + return this.textFont; + } + + /** + * アクティブエディタ変更通知用リスナの登録。 + * @param listener リスナ + */ + public void addChangeListener(ChangeListener listener){ + this.listenerList.add(ChangeListener.class, listener); + return; + } + + /** + * アクティブエディタ変更通知用リスナの削除。 + * @param listener リスナ + */ + public void removeChangeListener(ChangeListener listener){ + this.listenerList.remove(ChangeListener.class, listener); + return; + } + + /** + * アクティブエディタ変更通知を行う。 + */ + private void fireChangeActive(){ + ChangeEvent event = new ChangeEvent(this); + + ChangeListener[] listeners = + this.listenerList.getListeners(ChangeListener.class); + for(ChangeListener listener : listeners){ + listener.stateChanged(event); + } + + return; + } + + /** + * {@inheritDoc} + * エディタのフォーカス取得とともにアクティブ状態にする。 + * @param event {@inheritDoc} + */ + public void focusGained(FocusEvent event){ + Object source = event.getSource(); + if( ! (source instanceof JTextComponent) ) return; + JTextComponent textComp = (JTextComponent) source; + + Document document = textComp.getDocument(); + TalkEditor editor = getEditorFromDocument(document); + + setActiveEditor(editor); + + return; + } + + /** + * {@inheritDoc} + * @param event {@inheritDoc} + */ + public void focusLost(FocusEvent event){ + // NOTHING + return; + } + + /** + * {@inheritDoc} + * @return {@inheritDoc} + */ + public Dimension getPreferredScrollableViewportSize(){ + Dimension result = getPreferredSize(); + return result; + } + + /** + * {@inheritDoc} + * 横スクロールバーを極力出さないようレイアウトでがんばる。 + * @return {@inheritDoc} + */ + public boolean getScrollableTracksViewportWidth(){ + return true; + } + + /** + * {@inheritDoc} + * 縦スクロールバーを出しても良いのでレイアウトでがんばらない。 + * @return {@inheritDoc} + */ + public boolean getScrollableTracksViewportHeight(){ + return false; + } + + /** + * {@inheritDoc} + * @param visibleRect {@inheritDoc} + * @param orientation {@inheritDoc} + * @param direction {@inheritDoc} + * @return {@inheritDoc} + */ + public int getScrollableBlockIncrement(Rectangle visibleRect, + int orientation, + int direction ){ + if(orientation == SwingConstants.VERTICAL){ + return visibleRect.height; + } + return 10; + } + + /** + * {@inheritDoc} + * @param visibleRect {@inheritDoc} + * @param orientation {@inheritDoc} + * @param direction {@inheritDoc} + * @return {@inheritDoc} + */ + public int getScrollableUnitIncrement(Rectangle visibleRect, + int orientation, + int direction ){ + return 30; // TODO フォント高の1.5倍くらい? + } + + /** + * エディタ内のカーソル移動を監視するための、 + * カスタム化したナビゲーションフィルター。 + * 必要に応じてエディタ間カーソル移動を行う。 + */ + private class CustomNavigation extends NavigationFilter{ + + /** + * コンストラクタ。 + */ + public CustomNavigation(){ + super(); + return; + } + + /** + * {@inheritDoc} + * カーソル移動が行き詰まった場合、 + * 隣接するエディタ間でカーソル移動を行う。 + * @param text {@inheritDoc} + * @param pos {@inheritDoc} + * @param bias {@inheritDoc} + * @param direction {@inheritDoc} + * @param biasRet {@inheritDoc} + * @return {@inheritDoc} + * @throws javax.swing.text.BadLocationException {@inheritDoc} + */ + @Override + public int getNextVisualPositionFrom(JTextComponent text, + int pos, + Bias bias, + int direction, + Bias[] biasRet ) + throws BadLocationException { + int result = super.getNextVisualPositionFrom(text, + pos, + bias, + direction, + biasRet ); + if(result != pos) return result; + + switch(direction){ + case SwingConstants.WEST: + case SwingConstants.NORTH: + backwardEditor(); + break; + case SwingConstants.EAST: + case SwingConstants.SOUTH: + forwardEditor(); + break; + default: + assert false; + } + + return result; + } + } + + /** + * エディタの内容変更を監視し、随時エディタ間調整を行う。 + */ + private class DocWatcher implements DocumentListener{ + + /** + * コンストラクタ。 + */ + public DocWatcher(){ + super(); + return; + } + + /** + * {@inheritDoc} + * @param event {@inheritDoc} + */ + public void changedUpdate(DocumentEvent event){ + detachAdjustTask(event); + return; + } + + /** + * {@inheritDoc} + * @param event {@inheritDoc} + */ + public void insertUpdate(DocumentEvent event){ + detachAdjustTask(event); + return; + } + + /** + * {@inheritDoc} + * @param event {@inheritDoc} + */ + public void removeUpdate(DocumentEvent event){ + detachAdjustTask(event); + return; + } + } + +} diff --git a/src/main/java/jp/sourceforge/jindolf/EnvInfo.java b/src/main/java/jp/sourceforge/jindolf/EnvInfo.java new file mode 100644 index 0000000..2f29ed0 --- /dev/null +++ b/src/main/java/jp/sourceforge/jindolf/EnvInfo.java @@ -0,0 +1,149 @@ +/* + * environment information + * + * Copyright(c) 2009 olyutorskii + * $Id: EnvInfo.java 953 2009-12-06 16:42:14Z olyutorskii $ + */ + +package jp.sourceforge.jindolf; + +import java.io.File; +import java.text.NumberFormat; +import java.util.Set; +import java.util.SortedMap; +import java.util.TreeMap; + +/** + * 実行環境に関する各種情報。 + */ +public final class EnvInfo{ + + /** OS名。 */ + public static final String OS_NAME; + /** OSバージョン。 */ + public static final String OS_VERSION; + /** アーキテクチャ種別。 */ + public static final String OS_ARCH; + /** Java実行系ベンダ。 */ + public static final String JAVA_VENDOR; + /** Java実行形バージョン。 */ + public static final String JAVA_VERSION; + + private static final SortedMap propertyMap = + new TreeMap(); + + private static final SortedMap environmentMap = + new TreeMap(); + + private static final String[] classpaths; + + static{ + OS_NAME = getSecureProperty("os.name"); + OS_VERSION = getSecureProperty("os.version"); + OS_ARCH = getSecureProperty("os.arch"); + JAVA_VENDOR = getSecureProperty("java.vendor"); + JAVA_VERSION = getSecureProperty("java.version"); + + getSecureEnvironment("LANG"); + getSecureEnvironment("DISPLAY"); + + String classpath = getSecureProperty("java.class.path"); + if(classpath != null){ + classpaths = classpath.split(File.pathSeparator); + }else{ + classpaths = new String[0]; + } + } + + /** + * 可能ならシステムプロパティを読み込む。 + * @param key キー + * @return プロパティ値。セキュリティ上読み込み禁止の場合はnull。 + */ + private static String getSecureProperty(String key){ + String result; + try{ + result = System.getProperty(key); + if(result != null) propertyMap.put(key, result); + }catch(SecurityException e){ + result = null; + } + return result; + } + + /** + * 可能なら環境変数を読み込む。 + * @param name 環境変数名 + * @return 環境変数値。セキュリティ上読み込み禁止の場合はnull。 + */ + private static String getSecureEnvironment(String name){ + String result; + try{ + result = System.getenv(name); + if(result != null) environmentMap.put(name, result); + }catch(SecurityException e){ + result = null; + } + return result; + } + + /** + * VM詳細情報を文字列化する。 + * @return VM詳細情報 + */ + public static String getVMInfo(){ + StringBuilder result = new StringBuilder(); + NumberFormat nform = NumberFormat.getNumberInstance(); + + result.append("最大ヒープメモリ量: " + + nform.format(Jindolf.RUNTIME.maxMemory()) + " bytes\n"); + + result.append("\n"); + + result.append("起動時引数:\n"); + for(String arg : Jindolf.getOptionInfo().getInvokeArgList()){ + result.append(" ").append(arg).append("\n"); + } + + result.append("\n"); + + result.append("主要システムプロパティ:\n"); + Set propKeys = propertyMap.keySet(); + for(String propKey : propKeys){ + if(propKey.equals("java.class.path")) continue; + String value = propertyMap.get(propKey); + result.append(" "); + result.append(propKey).append("=").append(value).append("\n"); + } + + result.append("\n"); + + result.append("主要環境変数:\n"); + Set envKeys = environmentMap.keySet(); + for(String envKey : envKeys){ + String value = environmentMap.get(envKey); + result.append(" "); + result.append(envKey).append("=").append(value).append("\n"); + } + + result.append("\n"); + + result.append("クラスパス:\n"); + for(String path : classpaths){ + result.append(" "); + result.append(path).append("\n"); + } + + result.append("\n"); + + return result.toString(); + } + + /** + * 隠れコンストラクタ。 + */ + private EnvInfo(){ + throw new AssertionError(); + } + +} diff --git a/src/main/java/jp/sourceforge/jindolf/FaceIconSet.java b/src/main/java/jp/sourceforge/jindolf/FaceIconSet.java new file mode 100644 index 0000000..b4030d0 --- /dev/null +++ b/src/main/java/jp/sourceforge/jindolf/FaceIconSet.java @@ -0,0 +1,91 @@ +/* + * Face icon set + * + * Copyright(c) 2009 olyutorskii + * $Id: FaceIconSet.java 888 2009-11-04 06:23:35Z olyutorskii $ + */ + +package jp.sourceforge.jindolf; + +import java.util.HashMap; +import java.util.Map; + +/** + * 顔アイコンWiki表記のセット。 + */ +public class FaceIconSet{ + + private final String caption; + private final String author; + private final String urlText; + private final Map wikiMap = new HashMap(); + + /** + * コンストラクタ。 + * @param caption 説明 + * @param author 作者 + * @param urlText URL文字列 + */ + public FaceIconSet(String caption, String author, String urlText){ + super(); + this.caption = caption; + this.author = author; + this.urlText = urlText; + return; + } + + /** + * 説明文字列を得る。 + * @return 説明文字列 + */ + public String getCaption(){ + return this.caption; + } + + /** + * 作者名を得る。 + * @return 作者名 + */ + public String getAuthor(){ + return this.author; + } + + /** + * URL文字列を得る。 + * @return URL文字列 + */ + public String getUrlText(){ + return this.urlText; + } + + /** + * Avatarに対するWiki表記を登録する。 + * @param avatar Avatar + * @param wiki Wiki表記 + */ + public void registIconWiki(Avatar avatar, String wiki){ + this.wikiMap.put(avatar, wiki); + return; + } + + /** + * Avatarに対するWiki表記を取得する。 + * @param avatar Avatar + * @return Wiki表記 + */ + public String getAvatarIconWiki(Avatar avatar){ + String wiki = this.wikiMap.get(avatar); + return wiki; + } + + /** + * アイコンセットの文字列化。 + * コンボボックスアイテムの表記などで使われることを想定。 + * @return 文字列 + */ + @Override + public String toString(){ + return getCaption(); + } + +} diff --git a/src/main/java/jp/sourceforge/jindolf/FileUtils.java b/src/main/java/jp/sourceforge/jindolf/FileUtils.java new file mode 100644 index 0000000..1518e9d --- /dev/null +++ b/src/main/java/jp/sourceforge/jindolf/FileUtils.java @@ -0,0 +1,392 @@ +/* + * file utilities + * + * Copyright(c) 2009 olyutorskii + * $Id: FileUtils.java 952 2009-12-06 14:29:10Z olyutorskii $ + */ + +package jp.sourceforge.jindolf; + +import java.io.File; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.security.CodeSource; +import java.security.ProtectionDomain; +import java.util.Locale; + +/** + * 諸々のファイル操作ユーティリティ。 + * JRE1.6 API へのリフレクションアクセスを含む。 + */ +public final class FileUtils{ + + private static final String SCHEME_FILE = "file"; + + /** JRE1.6のjava.io.File#setReadableに相当。 */ + private static final Method METHOD_SETREADABLE; + /** JRE1.6のjava.io.File#setWritableに相当。 */ + private static final Method METHOD_SETWRITABLE; + /** Locale.ROOT代替品。 */ + private static final Locale ROOT = new Locale("", "", ""); + + static{ + Method method; + int modifiers; + + try{ + method = File.class.getMethod( + "setReadable", Boolean.TYPE, Boolean.TYPE); + modifiers = method.getModifiers(); + if( ! Modifier.isPublic(modifiers) ){ + method = null; + } + }catch(NoSuchMethodException e){ + method = null; + }catch(SecurityException e){ + method = null; + } + METHOD_SETREADABLE = method; + + try{ + method = File.class.getMethod( + "setWritable", Boolean.TYPE, Boolean.TYPE); + modifiers = method.getModifiers(); + if( ! Modifier.isPublic(modifiers) ){ + method = null; + } + }catch(NoSuchMethodException e){ + method = null; + }catch(SecurityException e){ + method = null; + } + METHOD_SETWRITABLE = method; + + assert ! ( isMacOSXFs() && isWindowsOSFs() ); + } + + /** + * なるべく自分にだけ許可を与え自分以外には許可を与えないように + * ファイル属性を操作する。 + * @param method setReadableかsetWritableのいずれかのメソッド。 + * nullならなにもしない。 + * @param file 操作対象のファイル。 + * @return 成功すればtrue + * @throws SecurityException セキュリティ上の許可が無い場合 + */ + private static boolean invokeOwnerOnly(Method method, File file) + throws SecurityException{ + if(method == null) return false; + if(file == null) throw new NullPointerException(); + + Object result1; + Object result2; + try{ + result1 = method.invoke(file, false, false); + result2 = method.invoke(file, true, true); + }catch(IllegalAccessException e){ + assert false; + return false; + }catch(IllegalArgumentException e){ + assert false; + return false; + }catch(ExceptionInInitializerError e){ + assert false; + return false; + }catch(InvocationTargetException e){ + Throwable cause = e.getCause(); + if(cause instanceof SecurityException){ + throw (SecurityException) cause; + }else if(cause instanceof RuntimeException){ + throw (RuntimeException) cause; + }else if(cause instanceof Error){ + throw (Error) cause; + }else{ + assert false; + } + return false; + } + + assert result1 instanceof Boolean; + assert result2 instanceof Boolean; + Boolean bresult1 = (Boolean) result1; + Boolean bresult2 = (Boolean) result2; + + return bresult1 && bresult2; + } + + /** + * なるべく自分にだけ読み書き許可を与え + * 自分以外には読み書き許可を与えないように + * ファイル属性を操作する。 + * JRE1.6環境でなければなにもしない。 + * @param file 操作対象ファイル + * @return 成功すればtrue + * @throws SecurityException セキュリティ上の許可が無い場合 + */ + public static boolean setOwnerOnlyAccess(File file) + throws SecurityException{ + boolean readresult = invokeOwnerOnly(METHOD_SETREADABLE, file); + boolean writeresult = invokeOwnerOnly(METHOD_SETWRITABLE, file); + return readresult & writeresult; + } + + /** + * 任意の絶対パスの祖先の内、存在するもっとも近い祖先を返す。 + * @param file 任意の絶対パス + * @return 存在するもっとも近い祖先。一つも存在しなければnull。 + * @throws IllegalArgumentException 引数が絶対パスでない + */ + public static File findExistsAncestor(File file) + throws IllegalArgumentException{ + if(file == null) return null; + if( ! file.isAbsolute() ) throw new IllegalArgumentException(); + if(file.exists()) return file; + File parent = file.getParentFile(); + return findExistsAncestor(parent); + } + + /** + * 任意の絶対パスのルートファイルシステムもしくはドライブレターを返す。 + * @param file 任意の絶対パス + * @return ルートファイルシステムもしくはドライブレター + * @throws IllegalArgumentException 引数が絶対パスでない + */ + public static File findRootFile(File file) + throws IllegalArgumentException{ + if( ! file.isAbsolute() ) throw new IllegalArgumentException(); + File parent = file.getParentFile(); + if(parent == null) return file; + return findRootFile(parent); + } + + /** + * 相対パスの絶対パス化を試みる。 + * @param file 対象パス + * @return 絶対パス。絶対化に失敗した場合は元の引数。 + */ + public static File supplyFullPath(File file){ + if(file.isAbsolute()) return file; + + File absFile; + + try{ + absFile = file.getAbsoluteFile(); + }catch(SecurityException e){ + return file; + } + + return absFile; + } + + /** + * 任意のディレクトリがアクセス可能な状態にあるか判定する。 + * アクセス可能の条件を満たすためには、与えられたパスが + * 存在し、 + * かつディレクトリであり、 + * かつ読み込み可能であり、 + * かつ書き込み可能 + * でなければならない。 + * @param path 任意のディレクトリ + * @return アクセス可能ならtrue + */ + public static boolean isAccessibleDirectory(File path){ + if(path == null) return false; + + if( ! path.exists() ) return false; + if( ! path.isDirectory() ) return false; + if( ! path.canRead() ) return false; + if( ! path.canWrite() ) return false; + + return true; + } + + /** + * クラスがローカルファイルからロードされたのであれば + * そのファイルを返す。 + * @param klass 任意のクラス + * @return ロード元ファイル。見つからなければnull。 + */ + public static File getClassSourceFile(Class klass){ + ProtectionDomain domain; + try{ + domain = klass.getProtectionDomain(); + }catch(SecurityException e){ + return null; + } + + CodeSource src = domain.getCodeSource(); + + URL location = src.getLocation(); + String scheme = location.getProtocol(); + if( ! scheme.equals(SCHEME_FILE) ) return null; + + URI uri; + try{ + uri = location.toURI(); + }catch(URISyntaxException e){ + assert false; + return null; + } + + File file = new File(uri); + + return file; + } + + /** + * すでに存在するJARファイルか判定する。 + * @param file 任意のファイル + * @return すでに存在するJARファイルであればtrue + */ + public static boolean isExistsJarFile(File file){ + if(file == null) return false; + if( ! file.exists() ) return false; + if( ! file.isFile() ) return false; + + String name = file.getName(); + if( ! name.matches("^.+\\.[jJ][aA][rR]$") ) return false; + + // TODO ファイル先頭マジックナンバーのテストも必要? + + return true; + } + + /** + * クラスがローカルJARファイルからロードされたのであれば + * その格納ディレクトリを返す。 + * @param klass 任意のクラス + * @return ロード元JARファイルの格納ディレクトリ。 + * JARが見つからない、もしくはロード元がJARファイルでなければnull。 + */ + public static File getJarDirectory(Class klass){ + File jarFile = getClassSourceFile(klass); + if(jarFile == null) return null; + + if( ! isExistsJarFile(jarFile) ){ + return null; + } + + return jarFile.getParentFile(); + } + + /** + * ホームディレクトリを得る。 + * システムプロパティuser.homeで示されたホームディレクトリを返す。 + * @return ホームディレクトリ。何らかの事情でnullを返す場合もあり。 + */ + public static File getHomeDirectory(){ + String homeProp; + try{ + homeProp = System.getProperty("user.home"); + }catch(SecurityException e){ + return null; + } + + File homeFile = new File(homeProp); + + return homeFile; + } + + /** + * MacOSX環境か否か判定する。 + * @return MacOSX環境ならtrue + */ + public static boolean isMacOSXFs(){ + if(File.separatorChar != '/') return false; + + String osName; + try{ + osName = System.getProperty("os.name"); + }catch(SecurityException e){ + return false; + } + + if(osName == null) return false; + + osName = osName.toLowerCase(ROOT); + + if(osName.startsWith("mac os x")){ + return true; + } + + return false; + } + + /** + * Windows環境か否か判定する。 + * @return Windows環境ならtrue + */ + public static boolean isWindowsOSFs(){ + if(File.separatorChar != '\\') return false; + + String osName; + try{ + osName = System.getProperty("os.name"); + }catch(SecurityException e){ + return false; + } + + if(osName == null) return false; + + osName = osName.toLowerCase(ROOT); + + if(osName.startsWith("windows")){ + return true; + } + + return false; + } + + /** + * アプリケーション設定ディレクトリを返す。 + * 存在の有無、アクセスの可否は関知しない。 + * @return アプリケーション設定ディレクトリ + */ + public static File getAppSetDir(){ + File home = getHomeDirectory(); + if(home == null) return null; + + File result = home; + + if(isMacOSXFs()){ + result = new File(result, "Library"); + result = new File(result, "Application Support"); + } + + // TODO Win環境での%APPDATA%サポート + + return result; + } + + /** + * ファイル名を表示するためのJLabel用HTML文字列を生成する。 + * Windows日本語環境では、バックスラッシュ記号が円通貨記号に置換される。 + * @param file 対象ファイル + * @return HTML文字列断片 + */ + public static String getHtmledFileName(File file){ + String pathName = file.getPath(); + + Locale locale = Locale.getDefault(); + String lang = locale.getLanguage(); + + if( FileUtils.isWindowsOSFs() && lang.equals("ja") ){ + pathName = pathName.replace(File.separator, "¥"); + } + + return "" + pathName + ""; + } + + /** + * 隠しコンストラクタ。 + */ + private FileUtils(){ + assert false; + throw new AssertionError(); + } + +} diff --git a/src/main/java/jp/sourceforge/jindolf/FilterPanel.java b/src/main/java/jp/sourceforge/jindolf/FilterPanel.java new file mode 100644 index 0000000..0749051 --- /dev/null +++ b/src/main/java/jp/sourceforge/jindolf/FilterPanel.java @@ -0,0 +1,522 @@ +/* + * Filter panel + * + * Copyright(c) 2008 olyutorskii + * $Id: FilterPanel.java 969 2009-12-24 16:12:58Z olyutorskii $ + */ + +package jp.sourceforge.jindolf; + +import java.awt.Container; +import java.awt.Frame; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.util.BitSet; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JComponent; +import javax.swing.JDialog; +import javax.swing.JPanel; +import javax.swing.JSeparator; +import javax.swing.SwingConstants; +import javax.swing.border.Border; +import javax.swing.event.ChangeEvent; +import javax.swing.event.ChangeListener; +import javax.swing.event.EventListenerList; +import jp.sourceforge.jindolf.corelib.EventFamily; +import jp.sourceforge.jindolf.corelib.TalkType; + +/** + * 発言フィルタ GUI。 + */ +@SuppressWarnings("serial") +public class FilterPanel extends JDialog + implements ActionListener, TopicFilter{ + + private static final int COLS = 4; + + private static final String FRAMETITLE = "発言フィルタ - " + Jindolf.TITLE; + + private final JCheckBox checkPublic = new JCheckBox("公開", true); + private final JCheckBox checkWolf = new JCheckBox("狼", true); + private final JCheckBox checkPrivate = new JCheckBox("独り言", true); + private final JCheckBox checkGrave = new JCheckBox("墓下", true); + private final JCheckBox checkExtra = new JCheckBox("Extra", true); + + private final JButton selAllButton = new JButton("全選択"); + private final JButton selNoneButton = new JButton("全解除"); + private final JButton negateButton = new JButton("反転"); + + private final JCheckBox checkRealtime = + new JCheckBox("リアルタイム更新", true); + private final JButton applyButton = new JButton("フィルタ適用"); + + private final Map cbMap = + new HashMap(); + private final List cbList = new LinkedList(); + + private final EventListenerList listeners = new EventListenerList(); + + /** + * 発言フィルタを生成する。 + * @param owner フレームオーナー + */ + public FilterPanel(Frame owner){ + super(owner, FRAMETITLE, false); + + GUIUtils.modifyWindowAttributes(this, true, false, true); + + JComponent topicPanel = createTopicPanel(); + JComponent avatarPanel = createAvatarPanel(); + JComponent buttonPanel = createButtonPanel(); + JComponent bottomPanel = createBottomPanel(); + design(topicPanel, avatarPanel, buttonPanel, bottomPanel); + + this.checkPublic.addActionListener(this); + this.checkWolf.addActionListener(this); + this.checkPrivate.addActionListener(this); + this.checkGrave.addActionListener(this); + this.checkExtra.addActionListener(this); + + for(JCheckBox avatarCheckBox : this.cbList){ + avatarCheckBox.addActionListener(this); + } + + this.selAllButton.addActionListener(this); + this.selNoneButton.addActionListener(this); + this.negateButton.addActionListener(this); + + this.checkRealtime.addActionListener(this); + this.applyButton.addActionListener(this); + this.applyButton.setEnabled(false); + + return; + } + + /** + * レイアウトデザインを行う。 + * @param topicPanel システムイベント選択 + * @param avatarPanel キャラ一覧 + * @param buttonPanel ボタン群 + * @param bottomPanel 下段パネル + */ + private void design(JComponent topicPanel, + JComponent avatarPanel, + JComponent buttonPanel, + JComponent bottomPanel ){ + Container content = getContentPane(); + + GridBagLayout layout = new GridBagLayout(); + GridBagConstraints constraints = new GridBagConstraints(); + + content.setLayout(layout); + + constraints.weightx = 1.0 / 5; + constraints.weighty = 1.0; + constraints.gridheight = 3; + constraints.fill = GridBagConstraints.BOTH; + constraints.anchor = GridBagConstraints.CENTER; + content.add(topicPanel, constraints); + + constraints.weightx = 0.0; + constraints.fill = GridBagConstraints.VERTICAL; + constraints.insets = new Insets(3, 0, 3, 0); + content.add(new JSeparator(SwingConstants.VERTICAL), constraints); + + constraints.weightx = 4.0 / 5; + constraints.weighty = 0.0; + constraints.gridheight = 1; + constraints.gridwidth = GridBagConstraints.REMAINDER; + constraints.fill = GridBagConstraints.HORIZONTAL; + constraints.insets = new Insets(0, 0, 0, 0); + content.add(buttonPanel, constraints); + + constraints.insets = new Insets(0, 3, 0, 3); + content.add(new JSeparator(), constraints); + + constraints.weighty = 1.0; + constraints.fill = GridBagConstraints.BOTH; + constraints.insets = new Insets(0, 0, 0, 0); + content.add(avatarPanel, constraints); + + constraints.weightx = 1.0; + constraints.gridwidth = GridBagConstraints.REMAINDER; + constraints.fill = GridBagConstraints.HORIZONTAL; + content.add(new JSeparator(SwingConstants.HORIZONTAL), constraints); + + constraints.fill = GridBagConstraints.NONE; + constraints.anchor = GridBagConstraints.NORTHEAST; + content.add(bottomPanel, constraints); + + return; + } + + /** + * システムイベントチェックボックス群パネルを作成。 + * @return システムイベントチェックボックス群パネル + */ + private JComponent createTopicPanel(){ + this.checkPublic.setToolTipText("誰にでも見える発言"); + this.checkWolf.setToolTipText("人狼同士にしか見えない発言"); + this.checkPrivate.setToolTipText("本人にしか見えない発言"); + this.checkGrave.setToolTipText("死者同士にしか見えない発言"); + this.checkExtra.setToolTipText("占い先や護衛先などのシステムメッセージ"); + + JPanel topicPanel = new JPanel(); + + Border border = BorderFactory.createEmptyBorder(2, 2, 2, 2); + topicPanel.setBorder(border); + + GridBagLayout layout = new GridBagLayout(); + GridBagConstraints constraints = new GridBagConstraints(); + + topicPanel.setLayout(layout); + + constraints.anchor = GridBagConstraints.WEST; + constraints.weightx = 1.0; + constraints.weighty = 1.0; + + constraints.gridwidth = GridBagConstraints.REMAINDER; + topicPanel.add(this.checkPublic, constraints); + + constraints.gridwidth = GridBagConstraints.REMAINDER; + topicPanel.add(this.checkWolf, constraints); + + constraints.gridwidth = GridBagConstraints.REMAINDER; + topicPanel.add(this.checkPrivate, constraints); + + constraints.gridwidth = GridBagConstraints.REMAINDER; + topicPanel.add(this.checkGrave, constraints); + + constraints.gridwidth = GridBagConstraints.REMAINDER; + topicPanel.add(this.checkExtra, constraints); + + return topicPanel; + } + + /** + * キャラ一覧チェックボックス群パネルを作成。 + * @return キャラ一覧チェックボックス群パネル + */ + private JComponent createAvatarPanel(){ + JPanel avatarPanel = new JPanel(); + + Border border = BorderFactory.createEmptyBorder(2, 2, 2, 2); + avatarPanel.setBorder(border); + + GridBagLayout layout = new GridBagLayout(); + GridBagConstraints constraints = new GridBagConstraints(); + + avatarPanel.setLayout(layout); + + constraints.weightx = 1.0 / COLS; + constraints.weighty = 1.0; + constraints.anchor = GridBagConstraints.WEST; + + int xPos = 0; + for(Avatar avatar : Avatar.getPredefinedAvatarList()){ + JCheckBox checkBox = new JCheckBox(avatar.getName(), true); + checkBox.setToolTipText(avatar.getJobTitle()); + this.cbList.add(checkBox); + if(xPos >= COLS - 1){ + constraints.gridwidth = GridBagConstraints.REMAINDER; + xPos = 0; + }else{ + constraints.gridwidth = 1; + xPos++; + } + avatarPanel.add(checkBox, constraints); + this.cbMap.put(avatar, checkBox); + } + + return avatarPanel; + } + + /** + * ボタン群パネルを生成。 + * @return ボタン群パネル + */ + private JComponent createButtonPanel(){ + this.selAllButton.setToolTipText( + "全キャラクタの発言を表示する"); + this.selNoneButton.setToolTipText( + "全キャラクタの発言をフィルタリングする"); + this.negateButton.setToolTipText( + "(表示⇔フィルタリング)の設定を反転させる"); + + JPanel buttonPanel = new JPanel(); + + GridBagLayout layout = new GridBagLayout(); + GridBagConstraints constraints = new GridBagConstraints(); + + buttonPanel.setLayout(layout); + + constraints.weightx = 1.0 / 3; + constraints.insets = new Insets(3, 3, 3, 3); + buttonPanel.add(this.selAllButton, constraints); + buttonPanel.add(this.selNoneButton, constraints); + buttonPanel.add(this.negateButton, constraints); + + return buttonPanel; + } + + /** + * 下段パネルを生成する。 + * @return 下段パネル + */ + private JComponent createBottomPanel(){ + JPanel panel = new JPanel(); + + GridBagLayout layout = new GridBagLayout(); + GridBagConstraints constraints = new GridBagConstraints(); + + panel.setLayout(layout); + + constraints.fill = GridBagConstraints.NONE; + constraints.insets = new Insets(3, 3, 3, 3); + panel.add(this.checkRealtime, constraints); + panel.add(this.applyButton, constraints); + + return panel; + } + + /** + * リスナを登録する。 + * @param listener リスナ + */ + public void addChangeListener(ChangeListener listener){ + this.listeners.add(ChangeListener.class, listener); + } + + /** + * リスナを削除する。 + * @param listener リスナ + */ + public void removeChangeListener(ChangeListener listener){ + this.listeners.remove(ChangeListener.class, listener); + } + + /** + * 全リスナを取得する。 + * @return リスナの配列 + */ + public ChangeListener[] getChangeListeners(){ + return this.listeners.getListeners(ChangeListener.class); + } + + /** + * 全リスナへフィルタ操作を通知する。 + */ + protected void fireCheckChanged(){ + ChangeEvent changeEvent = new ChangeEvent(this); + for(ChangeListener listener : getChangeListeners()){ + listener.stateChanged(changeEvent); + } + } + + /** + * ボタン状態の初期化。 + */ + public void initButtons(){ + this.checkPublic.setSelected(true); + this.checkWolf.setSelected(true); + this.checkPrivate.setSelected(true); + this.checkGrave.setSelected(true); + this.checkExtra.setSelected(true); + + this.selAllButton.doClick(); + + return; + } + + /** + * チェックボックスまたはボタン操作時にリスナとして呼ばれる。 + * {@inheritDoc} + * @param event イベント + */ + public void actionPerformed(ActionEvent event){ + Object source = event.getSource(); + + boolean isRealtime = this.checkRealtime.isSelected(); + + if(source == this.selAllButton){ + boolean hasChanged = false; + for(JCheckBox avatarCBox : this.cbList){ + if( ! avatarCBox.isSelected()){ + avatarCBox.setSelected(true); + hasChanged = true; + } + } + if(isRealtime && hasChanged){ + fireCheckChanged(); + } + }else if(source == this.selNoneButton){ + boolean hasChanged = false; + for(JCheckBox avatarCBox : this.cbList){ + if(avatarCBox.isSelected()){ + avatarCBox.setSelected(false); + hasChanged = true; + } + } + if(isRealtime && hasChanged){ + fireCheckChanged(); + } + }else if(source == this.negateButton){ + for(JCheckBox avatarCBox : this.cbList){ + if(avatarCBox.isSelected()){ + avatarCBox.setSelected(false); + }else{ + avatarCBox.setSelected(true); + } + } + if(isRealtime){ + fireCheckChanged(); + } + }else if(source == this.checkRealtime){ + if(isRealtime){ + this.applyButton.setEnabled(false); + fireCheckChanged(); + }else{ + this.applyButton.setEnabled(true); + } + }else if(source == this.applyButton){ + fireCheckChanged(); + }else if(source instanceof JCheckBox){ + if(isRealtime){ + fireCheckChanged(); + } + } + + return; + } + + /** + * {@inheritDoc} + * @param topic {@inheritDoc} + * @return {@inheritDoc} + */ + public boolean isFiltered(Topic topic){ + Talk talk; + if(topic instanceof Talk){ + talk = (Talk) topic; + }else if(topic instanceof SysEvent){ + SysEvent sysEvent = (SysEvent) topic; + if(sysEvent.getEventFamily() == EventFamily.EXTRA){ + if( ! this.checkExtra.isSelected() ){ + return true; + } + } + return false; + }else{ + return false; + } + + JCheckBox cbox; + + TalkType type = talk.getTalkType(); + switch(type){ + case PUBLIC: + cbox = this.checkPublic; + break; + case WOLFONLY: + cbox = this.checkWolf; + break; + case PRIVATE: + cbox = this.checkPrivate; + break; + case GRAVE: + cbox = this.checkGrave; + break; + default: + assert false; + return true; + } + if( ! cbox.isSelected()){ + return true; + } + + Avatar avatar = talk.getAvatar(); + cbox = this.cbMap.get(avatar); + if( cbox != null && ! cbox.isSelected()){ + return true; + } + + return false; + } + + /** + * {@inheritDoc} + * @return {@inheritDoc} + */ + public FilterContext getFilterContext(){ + return new FilterPanelContext(); + } + + /** + * {@inheritDoc} + * @param context {@inheritDoc} + * @return {@inheritDoc} + */ + public boolean isSame(FilterContext context){ + if(context == null) return false; + if( ! (context instanceof FilterPanelContext) ) return false; + FilterPanelContext argContext = (FilterPanelContext) context; + FilterPanelContext thisContext = + (FilterPanelContext) getFilterContext(); + + return thisContext.context.equals(argContext.context); + } + + /** + * カスタム化されたフィルタ状態。 + */ + private final class FilterPanelContext implements FilterContext{ + + private final BitSet context = new BitSet(); + + /** + * コンストラクタ。 + */ + public FilterPanelContext(){ + super(); + + int index = 0; + this.context.set(index++, + FilterPanel.this.checkPublic.isSelected()); + this.context.set(index++, + FilterPanel.this.checkWolf.isSelected()); + this.context.set(index++, + FilterPanel.this.checkPrivate.isSelected()); + this.context.set(index++, + FilterPanel.this.checkGrave.isSelected()); + this.context.set(index++, + FilterPanel.this.checkExtra.isSelected()); + + for(Avatar avatar : Avatar.getPredefinedAvatarList()){ + JCheckBox checkBox = FilterPanel.this.cbMap.get(avatar); + this.context.set(index++, checkBox.isSelected()); + } + + return; + } + + /** + * {@inheritDoc} + * @return {@inheritDoc} + */ + @Override + public String toString(){ + return this.context.toString(); + } + + } + +} diff --git a/src/main/java/jp/sourceforge/jindolf/FindPanel.java b/src/main/java/jp/sourceforge/jindolf/FindPanel.java new file mode 100644 index 0000000..12193ab --- /dev/null +++ b/src/main/java/jp/sourceforge/jindolf/FindPanel.java @@ -0,0 +1,764 @@ +/* + * Find panel + * + * Copyright(c) 2008 olyutorskii + * $Id: FindPanel.java 956 2009-12-13 15:14:07Z olyutorskii $ + */ + +package jp.sourceforge.jindolf; + +import java.awt.BorderLayout; +import java.awt.Component; +import java.awt.Container; +import java.awt.Frame; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.GridLayout; +import java.awt.Insets; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.awt.event.ItemEvent; +import java.awt.event.ItemListener; +import java.awt.event.WindowAdapter; +import java.awt.event.WindowEvent; +import java.beans.PropertyChangeEvent; +import java.beans.PropertyChangeListener; +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; +import javax.swing.BorderFactory; +import javax.swing.ComboBoxEditor; +import javax.swing.ComboBoxModel; +import javax.swing.DefaultListCellRenderer; +import javax.swing.Icon; +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JComboBox; +import javax.swing.JDialog; +import javax.swing.JLabel; +import javax.swing.JList; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JSeparator; +import javax.swing.border.Border; +import javax.swing.event.ChangeEvent; +import javax.swing.event.ChangeListener; +import javax.swing.event.EventListenerList; +import javax.swing.event.ListDataEvent; +import javax.swing.event.ListDataListener; +import javax.swing.text.JTextComponent; +import jp.sourceforge.jindolf.json.JsArray; +import jp.sourceforge.jindolf.json.JsObject; +import jp.sourceforge.jindolf.json.JsValue; + +/** + * 検索パネルGUI。 + */ +@SuppressWarnings("serial") +public class FindPanel extends JDialog + implements ActionListener, + ItemListener, + ChangeListener, + PropertyChangeListener { + + private static final String HIST_FILE = "searchHistory.json"; + private static final String FRAMETITLE = "発言検索 - " + Jindolf.TITLE; + private static final String LABEL_REENTER = "再入力"; + private static final String LABEL_IGNORE = "無視して検索をキャンセル"; + + private final JComboBox findBox = new JComboBox(); + private final JButton searchButton = new JButton("検索"); + private final JButton clearButton = new JButton("クリア"); + private final JCheckBox capitalSwitch = + new JCheckBox("大文字/小文字を区別する"); + private final JCheckBox regexSwitch = + new JCheckBox("正規表現"); + private final JCheckBox dotallSwitch = + new JCheckBox("正規表現 \".\" を行末記号にもマッチさせる"); + private final JCheckBox multilineSwitch = + new JCheckBox("正規表現 \"^\" や \"$\" を" + +"行末記号の前後に反応させる"); + private final JCheckBox bulkSearchSwitch = + new JCheckBox("全日程を一括検索"); + private final JButton closeButton = new JButton("キャンセル"); + + private final CustomModel model = new CustomModel(); + + private JsObject loadedHistory = null; + + private boolean canceled = false; + private RegexPattern regexPattern = null; + + /** + * 検索パネルを生成する。 + * @param owner 親フレーム。 + */ + public FindPanel(Frame owner){ + super(owner, FRAMETITLE, true); + + GUIUtils.modifyWindowAttributes(this, true, false, true); + + setDefaultCloseOperation(DO_NOTHING_ON_CLOSE); + addWindowListener(new WindowAdapter(){ + @Override + public void windowClosing(WindowEvent event){ + actionCancel(); + return; + } + }); + + design(); + + this.findBox.setModel(this.model); + this.findBox.setToolTipText("検索文字列を入力してください"); + this.findBox.setEditable(true); + this.findBox.setRenderer(new CustomRenderer()); + this.findBox.setMaximumRowCount(15); + + ComboBoxEditor editor = this.findBox.getEditor(); + modifyComboBoxEditor(editor); + this.findBox.addPropertyChangeListener("UI", this); + + this.searchButton.setToolTipText("発言内容を検索する"); + this.clearButton.setToolTipText("入力をクリアする"); + + this.findBox.addItemListener(this); + this.searchButton.addActionListener(this); + this.clearButton.addActionListener(this); + this.regexSwitch.addChangeListener(this); + this.closeButton.addActionListener(this); + + setRegexPattern(null); + + return; + } + + /** + * デザイン・レイアウトを行う。 + */ + private void design(){ + Container content = getContentPane(); + + GridBagLayout layout = new GridBagLayout(); + GridBagConstraints constraints = new GridBagConstraints(); + + content.setLayout(layout); + + constraints.insets = new Insets(2, 2, 2, 2); + + constraints.weightx = 1.0; + constraints.fill = GridBagConstraints.HORIZONTAL; + constraints.gridwidth = 2; + Border border = + BorderFactory + .createTitledBorder("検索文字列を入力してください"); + JPanel panel = new JPanel(); + panel.setLayout(new BorderLayout()); + panel.add(this.findBox, BorderLayout.CENTER); + panel.setBorder(border); + content.add(panel, constraints); + + constraints.weightx = 0.0; + constraints.fill = GridBagConstraints.NONE; + constraints.gridwidth = GridBagConstraints.REMAINDER; + constraints.anchor = GridBagConstraints.SOUTH; + content.add(this.searchButton, constraints); + + constraints.gridwidth = GridBagConstraints.REMAINDER; + constraints.anchor = GridBagConstraints.WEST; + content.add(this.clearButton, constraints); + + constraints.gridwidth = GridBagConstraints.REMAINDER; + constraints.anchor = GridBagConstraints.WEST; + content.add(this.capitalSwitch, constraints); + + constraints.gridwidth = GridBagConstraints.REMAINDER; + constraints.anchor = GridBagConstraints.WEST; + content.add(this.regexSwitch, constraints); + + JPanel regexPanel = new JPanel(); + regexPanel.setBorder(BorderFactory.createTitledBorder("")); + regexPanel.setLayout(new GridLayout(2, 1)); + regexPanel.add(this.dotallSwitch); + regexPanel.add(this.multilineSwitch); + + constraints.gridwidth = GridBagConstraints.REMAINDER; + constraints.anchor = GridBagConstraints.WEST; + content.add(regexPanel, constraints); + + constraints.weightx = 1.0; + constraints.gridwidth = GridBagConstraints.REMAINDER; + constraints.fill = GridBagConstraints.HORIZONTAL; + content.add(new JSeparator(), constraints); + + constraints.weightx = 0.0; + constraints.gridwidth = GridBagConstraints.REMAINDER; + constraints.anchor = GridBagConstraints.WEST; + constraints.fill = GridBagConstraints.NONE; + content.add(this.bulkSearchSwitch, constraints); + + constraints.weightx = 1.0; + constraints.gridwidth = GridBagConstraints.REMAINDER; + constraints.fill = GridBagConstraints.HORIZONTAL; + content.add(new JSeparator(), constraints); + + constraints.weightx = 1.0; + constraints.gridwidth = GridBagConstraints.REMAINDER; + constraints.anchor = GridBagConstraints.EAST; + constraints.fill = GridBagConstraints.NONE; + content.add(this.closeButton, constraints); + + return; + } + + /** + * {@inheritDoc} + * 検索ダイアログを表示・非表示する。 + * @param show 表示フラグ。真なら表示。{@inheritDoc} + */ + @Override + public void setVisible(boolean show){ + super.setVisible(show); + getRootPane().setDefaultButton(this.searchButton); + this.findBox.requestFocusInWindow(); + return; + } + + /** + * ダイアログが閉じられた原因を判定する。 + * @return キャンセルもしくはクローズボタンでダイアログが閉じられたらtrue + */ + public boolean isCanceled(){ + return this.canceled; + } + + /** + * 一括検索が指定されたか否か返す。 + * @return 一括検索が指定されたらtrue + */ + public boolean isBulkSearch(){ + return this.bulkSearchSwitch.isSelected(); + } + + /** + * キャンセルボタン押下処理。 + * このモーダルダイアログを閉じる。 + */ + private void actionCancel(){ + this.canceled = true; + setVisible(false); + dispose(); + return; + } + + /** + * 検索ボタン押下処理。 + * このモーダルダイアログを閉じる。 + */ + private void actionSubmit(){ + Object selected = this.findBox.getSelectedItem(); + if(selected == null){ + this.regexPattern = null; + return; + } + String edit = selected.toString(); + + boolean isRegex = this.regexSwitch.isSelected(); + + int flag = 0x00000000; + if( ! this.capitalSwitch.isSelected() ){ + flag |= RegexPattern.IGNORECASEFLAG; + } + if(this.dotallSwitch.isSelected()) flag |= Pattern.DOTALL; + if(this.multilineSwitch.isSelected()) flag |= Pattern.MULTILINE; + + try{ + this.regexPattern = new RegexPattern(edit, isRegex, flag); + }catch(PatternSyntaxException e){ + this.regexPattern = null; + if(showRegexError(e)){ + return; + } + actionCancel(); + return; + } + + this.model.addHistory(this.regexPattern); + + this.canceled = false; + setVisible(false); + dispose(); + + return; + } + + /** + * 正規表現パターン異常系のダイアログ表示。 + * @param e 正規表現構文エラー + * @return 再入力が押されたらtrue。それ以外はfalse。 + */ + private boolean showRegexError(PatternSyntaxException e){ + String pattern = e.getPattern(); + + String position = ""; + int index = e.getIndex(); + if(0 <= index && index <= pattern.length() - 1){ + char errChar = pattern.charAt(index); + position = "エラーの発生箇所は、おおよそ" + + (index+1) + "文字目 [ " + errChar + " ] " + +"かその前後と推測されます。\n"; + } + + String message = + "入力された検索文字列 [ " + pattern + " ] は" + +"正しい正規表現として認識されませんでした。\n" + +position + +"正規表現の書き方は" + +" [ http://java.sun.com/j2se/1.5.0/ja/docs/ja/api/" + +"java/util/regex/Pattern.html#sum ] " + +"を参照してください。\n" + +"ただの文字列を検索したい場合は" + +"「正規表現」のチェックボックスを外しましょう。\n" + ; + + Object[] buttons = new Object[2]; + buttons[0] = LABEL_REENTER; + buttons[1] = LABEL_IGNORE; + Icon icon = null; + + int optionNo = JOptionPane.showOptionDialog(this, + message, + "不正な正規表現", + JOptionPane.YES_NO_OPTION, + JOptionPane.ERROR_MESSAGE, + icon, + buttons, + LABEL_REENTER); + + if(optionNo == JOptionPane.CLOSED_OPTION) return false; + if(buttons[optionNo].equals(LABEL_REENTER)) return true; + if(buttons[optionNo].equals(LABEL_IGNORE) ) return false; + + return true; + } + + /** + * 現時点での検索パターンを得る。 + * @return 検索パターン + */ + public RegexPattern getRegexPattern(){ + return this.regexPattern; + } + + /** + * 検索パターンを設定する。 + * @param pattern 検索パターン + */ + public final void setRegexPattern(RegexPattern pattern){ + if(pattern == null) this.regexPattern = CustomModel.INITITEM; + else this.regexPattern = pattern; + + String edit = this.regexPattern.getEditSource(); + this.findBox.getEditor().setItem(edit); + + this.regexSwitch.setSelected(this.regexPattern.isRegex()); + + int initflag = this.regexPattern.getRegexFlag(); + this.capitalSwitch.setSelected( + (initflag & RegexPattern.IGNORECASEFLAG) == 0); + this.dotallSwitch.setSelected((initflag & Pattern.DOTALL) != 0); + this.multilineSwitch.setSelected((initflag & Pattern.MULTILINE) != 0); + + maskRegexUI(); + + return; + } + + /** + * {@inheritDoc} + * ボタン操作時にリスナとして呼ばれる。 + * @param event イベント {@inheritDoc} + */ + public void actionPerformed(ActionEvent event){ + Object source = event.getSource(); + if(source == this.closeButton){ + actionCancel(); + }else if(source == this.searchButton){ + actionSubmit(); + }else if(source == this.clearButton){ + this.findBox.getEditor().setItem(""); + this.findBox.requestFocusInWindow(); + } + return; + } + + /** + * {@inheritDoc} + * コンボボックスのアイテム選択リスナ。 + * @param event アイテム選択イベント {@inheritDoc} + */ + public void itemStateChanged(ItemEvent event){ + int stateChange = event.getStateChange(); + if(stateChange != ItemEvent.SELECTED) return; + + Object item = event.getItem(); + if( ! (item instanceof RegexPattern) ) return; + RegexPattern regex = (RegexPattern) item; + + setRegexPattern(regex); + + return; + } + + /** + * {@inheritDoc} + * チェックボックス操作のリスナ。 + * @param event チェックボックス操作イベント {@inheritDoc} + */ + public void stateChanged(ChangeEvent event){ + if(event.getSource() != this.regexSwitch) return; + maskRegexUI(); + return; + } + + /** + * 正規表現でしか使わないUIのマスク処理。 + */ + private void maskRegexUI(){ + boolean isRegex = this.regexSwitch.isSelected(); + this.dotallSwitch .setEnabled(isRegex); + this.multilineSwitch.setEnabled(isRegex); + return; + } + + /** + * {@inheritDoc} + * コンボボックスのUI変更通知を受け取るリスナ。 + * @param event UI差し替えイベント {@inheritDoc} + */ + public void propertyChange(PropertyChangeEvent event){ + if( ! event.getPropertyName().equals("UI") ) return; + if(event.getSource() != this.findBox) return; + + ComboBoxEditor editor = this.findBox.getEditor(); + modifyComboBoxEditor(editor); + + return; + } + + /** + * コンボボックスエディタを修飾する。 + * マージン修飾と等幅フォントをいじる。 + * @param editor エディタ + */ + private void modifyComboBoxEditor(ComboBoxEditor editor){ + if(editor == null) return; + + Component editComp = editor.getEditorComponent(); + if(editComp == null) return; + + if(editComp instanceof JTextComponent){ + JTextComponent textEditor = (JTextComponent) editComp; + textEditor.setComponentPopupMenu(new TextPopup()); + } + + GUIUtils.addMargin(editComp, 1, 4, 1, 4); + + return; + } + + /** + * 検索履歴をロードする。 + */ + public void loadHistory(){ + JsValue value = ConfigFile.loadJson(new File(HIST_FILE)); + if(value == null) return; + + if( ! (value instanceof JsObject) ) return; + JsObject root = (JsObject) value; + + value = root.getValue("history"); + if( ! (value instanceof JsArray) ) return; + JsArray array = (JsArray) value; + + for(JsValue elem : array){ + if( ! (elem instanceof JsObject) ) continue; + JsObject regObj = (JsObject) elem; + RegexPattern regex = RegexPattern.decodeJson(regObj); + if(regex == null) continue; + this.model.addHistory(regex); + } + + this.loadedHistory = root; + + return; + } + + /** + * 検索履歴をセーブする。 + */ + public void saveHistory(){ + AppSetting setting = Jindolf.getAppSetting(); + if( ! setting.useConfigPath() ) return; + File configPath = setting.getConfigPath(); + if(configPath == null) return; + + JsObject root = new JsObject(); + JsArray array = new JsArray(); + root.putValue("history", array); + + List history = this.model.getOriginalHistoryList(); + history = new ArrayList(history); + Collections.reverse(history); + for(RegexPattern regex : history){ + JsObject obj = RegexPattern.encodeJson(regex); + array.add(obj); + } + + if(this.loadedHistory != null){ + if(this.loadedHistory.equals(root)) return; + } + + ConfigFile.saveJson(new File(HIST_FILE), root); + + return; + } + + /** + * コンボボックスの独自レンダラ。 + */ + private static class CustomRenderer extends DefaultListCellRenderer{ + + /** + * コンストラクタ。 + */ + public CustomRenderer(){ + super(); + return; + } + + /** + * {@inheritDoc} + * @param list {@inheritDoc} + * @param value {@inheritDoc} + * @param index {@inheritDoc} + * @param isSelected {@inheritDoc} + * @param cellHasFocus {@inheritDoc} + * @return {@inheritDoc} + */ + @Override + public Component getListCellRendererComponent( + JList list, + Object value, + int index, + boolean isSelected, + boolean cellHasFocus ){ + if(value instanceof JSeparator){ + return (JSeparator) value; + } + + JLabel superLabel = + (JLabel) super.getListCellRendererComponent(list, + value, + index, + isSelected, + cellHasFocus); + + if(value instanceof RegexPattern){ + RegexPattern regexPattern = (RegexPattern) value; + String text; + if(regexPattern.isRegex()){ + text = "[R] " + regexPattern.getEditSource(); + }else{ + text = regexPattern.getEditSource(); + } + text += regexPattern.getComment(); + + superLabel.setText(text); + } + + GUIUtils.addMargin(superLabel, 1, 4, 1, 4); + + return superLabel; + } + } + + /** + * コンボボックスの独自データモデル。 + */ + private static class CustomModel implements ComboBoxModel{ + + private static final int HISTORY_MAX = 7; + private static final RegexPattern INITITEM = + new RegexPattern( + "", false, RegexPattern.IGNORECASEFLAG | Pattern.DOTALL); + private static final List PREDEF_PATTERN_LIST = + new LinkedList(); + + static{ + PREDEF_PATTERN_LIST.add( + new RegexPattern("【[^】]*】", + true, + Pattern.DOTALL, + " ※ 重要事項") ); + PREDEF_PATTERN_LIST.add( + new RegexPattern("[■●▼★□○▽☆〇◯∇]", + true, + Pattern.DOTALL, + " ※ 議題") ); + PREDEF_PATTERN_LIST.add( + new RegexPattern("Jindolf", + false, + RegexPattern.IGNORECASEFLAG, + " ※ 宣伝") ); + } + + private final List history = + new LinkedList(); + private final JSeparator separator1st = new JSeparator(); + private final JSeparator separator2nd = new JSeparator(); + private Object selected; + private final EventListenerList listenerList = + new EventListenerList(); + + /** + * コンストラクタ。 + */ + public CustomModel(){ + super(); + return; + } + + /** + * {@inheritDoc} + * @return {@inheritDoc} + */ + public Object getSelectedItem(){ + return this.selected; + } + + /** + * {@inheritDoc} + * @param item {@inheritDoc} + */ + public void setSelectedItem(Object item){ + if(item instanceof JSeparator) return; + this.selected = item; + return; + } + + /** + * {@inheritDoc} + * @param index {@inheritDoc} + * @return {@inheritDoc} + */ + public Object getElementAt(int index){ + int historySize = this.history.size(); + + if(index == 0){ + return INITITEM; + } + if(index == 1){ + return this.separator1st; + } + if(2 <= index && index <= 1 + historySize){ + return this.history.get(index - 2); + } + if(index == historySize + 2){ + return this.separator2nd; + } + if(historySize + 3 <= index){ + return PREDEF_PATTERN_LIST.get(index - 1 + - 1 + - historySize + - 1 ); + } + + return null; + } + + /** + * {@inheritDoc} + * @return {@inheritDoc} + */ + public int getSize(){ + int size = 1; + size += 1; // first separator + size += this.history.size(); + size += 1; // second separator + size += PREDEF_PATTERN_LIST.size(); + return size; + } + + /** + * {@inheritDoc} + * @param listener {@inheritDoc} + */ + public void addListDataListener(ListDataListener listener){ + this.listenerList.add(ListDataListener.class, listener); + return; + } + + /** + * {@inheritDoc} + * @param listener {@inheritDoc} + */ + public void removeListDataListener(ListDataListener listener){ + this.listenerList.remove(ListDataListener.class, listener); + return; + } + + /** + * 検索履歴ヒストリ追加。 + * @param regexPattern 検索履歴 + */ + public void addHistory(RegexPattern regexPattern){ + if(regexPattern == null) return; + if(regexPattern.equals(INITITEM)) return; + if(PREDEF_PATTERN_LIST.contains(regexPattern)) return; + if(this.history.contains(regexPattern)){ + this.history.remove(regexPattern); + } + + this.history.add(0, regexPattern); + + while(this.history.size() > HISTORY_MAX){ + this.history.remove(HISTORY_MAX); + } + + fire(); + + return; + } + + /** + * プリセットでない検索ヒストリリストを返す。 + * @return 検索ヒストリリスト + */ + public List getOriginalHistoryList(){ + return Collections.unmodifiableList(this.history); + } + + /** + * ヒストリ追加イベント発火。 + */ + private void fire(){ + ListDataEvent event = + new ListDataEvent(this, + ListDataEvent.CONTENTS_CHANGED, + 0, getSize() - 1 ); + ListDataListener[] listeners = + this.listenerList.getListeners(ListDataListener.class); + for(ListDataListener listener : listeners){ + listener.contentsChanged(event); + } + return; + } + } + + // TODO ブックマーク機能との統合 +} diff --git a/src/main/java/jp/sourceforge/jindolf/FontChooser.java b/src/main/java/jp/sourceforge/jindolf/FontChooser.java new file mode 100644 index 0000000..02a88fd --- /dev/null +++ b/src/main/java/jp/sourceforge/jindolf/FontChooser.java @@ -0,0 +1,637 @@ +/* + * font chooser + * + * Copyright(c) 2008 olyutorskii + * $Id: FontChooser.java 956 2009-12-13 15:14:07Z olyutorskii $ + */ + +package jp.sourceforge.jindolf; + +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Container; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.awt.Rectangle; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.awt.event.ItemEvent; +import java.awt.event.ItemListener; +import java.awt.font.FontRenderContext; +import java.awt.geom.AffineTransform; +import java.awt.geom.Rectangle2D; +import java.io.IOException; +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JComboBox; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JList; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTextField; +import javax.swing.ListSelectionModel; +import javax.swing.border.Border; +import javax.swing.event.ListSelectionEvent; +import javax.swing.event.ListSelectionListener; + +/** + * 発言表示フォント選択パネル。 + */ +@SuppressWarnings("serial") +public class FontChooser extends JPanel + implements ListSelectionListener, + ActionListener, + ItemListener{ + + private static final Integer[] POINT_SIZES = { + 8, 10, 12, 16, 18, 24, 32, 36, 48, 72, // TODO これで十分? + }; + private static final CharSequence PREVIEW_CONTENT; + + static{ + CharSequence resourceText; + try{ + resourceText = Jindolf.loadResourceText("resources/preview.txt"); + }catch(IOException e){ + resourceText = "ABC"; + } + PREVIEW_CONTENT = resourceText; + } + + private FontInfo fontInfo; + private FontInfo lastFontInfo; + + private final JList familySelector; + private final JComboBox sizeSelector; + private final JCheckBox isBoldCheck; + private final JCheckBox isItalicCheck; + private final JCheckBox useTextAntiAliaseCheck; + private final JCheckBox useFractionalCheck; + private final JLabel maxBounds; + private final JTextField decodeName; + private final FontPreview preview; + private final JButton resetDefault; + + private boolean maskListener = false; + + /** + * コンストラクタ。 + */ + public FontChooser(){ + this(FontInfo.DEFAULT_FONTINFO); + return; + } + + /** + * コンストラクタ。 + * @param fontInfo 初期フォント設定 + * @throws NullPointerException 引数がnull + */ + public FontChooser(FontInfo fontInfo) + throws NullPointerException{ + super(); + + if(fontInfo == null) throw new NullPointerException(); + this.fontInfo = fontInfo; + this.lastFontInfo = fontInfo; + + Jindolf.logger().info( + "デフォルトの発言表示フォントに" + + this.fontInfo.getFont() + + "が選択されました" ); + Jindolf.logger().info( + "発言表示のアンチエイリアス指定に" + + this.fontInfo.getFontRenderContext().isAntiAliased() + + "が指定されました" ); + Jindolf.logger().info( + "発言表示のFractional指定に" + + this.fontInfo.getFontRenderContext().usesFractionalMetrics() + + "が指定されました" ); + + this.familySelector = new JList(FontUtils.createFontSet().toArray()); + this.familySelector.setVisibleRowCount(-1); + this.familySelector + .setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + + this.sizeSelector = new JComboBox(); + this.sizeSelector.setEditable(true); + this.sizeSelector.setActionCommand(ActionManager.CMD_FONTSIZESEL); + for(Integer size : POINT_SIZES){ + this.sizeSelector.addItem(size); + } + + this.isBoldCheck = new JCheckBox("ボールド"); + this.isItalicCheck = new JCheckBox("イタリック"); + this.useTextAntiAliaseCheck = new JCheckBox("アンチエイリアス"); + this.useFractionalCheck = new JCheckBox("サブピクセル精度"); + + this.maxBounds = new JLabel(); + + this.decodeName = new JTextField(); + this.decodeName.setEditable(false); + this.decodeName.setMargin(new Insets(1, 4, 1, 4)); + this.decodeName.setComponentPopupMenu(new TextPopup()); + Monodizer.monodize(this.decodeName); + + this.preview = new FontPreview(PREVIEW_CONTENT, this.fontInfo); + + this.resetDefault = new JButton("出荷時に戻す"); + this.resetDefault.addActionListener(this); + + design(this); + updateControlls(); + updatePreview(); + + this.familySelector.addListSelectionListener(this); + this.sizeSelector .addActionListener(this); + + this.isBoldCheck .addItemListener(this); + this.isItalicCheck .addItemListener(this); + this.useTextAntiAliaseCheck.addItemListener(this); + this.useFractionalCheck .addItemListener(this); + + return; + } + + /** + * GUIのデザイン、レイアウトを行う。 + * @param content コンテナ + */ + private void design(Container content){ + GridBagLayout layout = new GridBagLayout(); + GridBagConstraints constraints = new GridBagConstraints(); + + content.setLayout(layout); + + Border border; + JPanel panel; + + JComponent fontPref = createFontPrefPanel(); + + constraints.insets = new Insets(5, 5, 5, 5); + + constraints.weightx = 1.0; + constraints.weighty = 0.0; + constraints.gridwidth = GridBagConstraints.REMAINDER; + constraints.fill = GridBagConstraints.BOTH; + content.add(fontPref, constraints); + + constraints.weightx = 1.0; + constraints.weighty = 1.0; + constraints.gridwidth = GridBagConstraints.REMAINDER; + constraints.fill = GridBagConstraints.BOTH; + border = BorderFactory.createTitledBorder("プレビュー"); + panel = new JPanel(); + panel.add(this.preview); + panel.setBorder(border); + content.add(createPreviewPanel(), constraints); + + constraints.weightx = 1.0; + constraints.weighty = 0.0; + constraints.gridwidth = GridBagConstraints.REMAINDER; + constraints.fill = GridBagConstraints.HORIZONTAL; + content.add(createFontDecodePanel(), constraints); + + constraints.insets = new Insets(5, 5, 5, 5); + constraints.weightx = 1.0; + constraints.weighty = 0.0; + constraints.gridwidth = 1; + constraints.fill = GridBagConstraints.HORIZONTAL; + content.add(this.maxBounds, constraints); + + constraints.weightx = 0.0; + constraints.weighty = 0.0; + constraints.gridwidth = GridBagConstraints.REMAINDER; + constraints.fill = GridBagConstraints.HORIZONTAL; + content.add(this.resetDefault, constraints); + + return; + } + + /** + * フォント設定画面を生成する。 + * @return フォント設定画面 + */ + private JComponent createFontPrefPanel(){ + JPanel result = new JPanel(); + + GridBagLayout layout = new GridBagLayout(); + GridBagConstraints constraints = new GridBagConstraints(); + result.setLayout(layout); + + Border border; + + constraints.insets = new Insets(0, 0, 0, 5); + constraints.weightx = 1.0; + constraints.weighty = 0.0; + constraints.gridheight = GridBagConstraints.REMAINDER; + constraints.fill = GridBagConstraints.BOTH; + border = BorderFactory.createEmptyBorder(1, 1, 1, 1); + this.familySelector.setBorder(border); + JScrollPane familyScroller = new JScrollPane(this.familySelector); + border = BorderFactory.createTitledBorder("フォントファミリ選択"); + JPanel familyPanel = new JPanel(); + familyPanel.setLayout(new BorderLayout()); + familyPanel.add(familyScroller, BorderLayout.CENTER); + familyPanel.setBorder(border); + result.add(familyPanel, constraints); + + constraints.insets = new Insets(0, 0, 0, 0); + constraints.weightx = 0.0; + constraints.gridheight = 1; + constraints.fill = GridBagConstraints.HORIZONTAL; + constraints.anchor = GridBagConstraints.WEST; + + border = BorderFactory.createTitledBorder("ポイントサイズ指定"); + JPanel panel = new JPanel(); + panel.add(this.sizeSelector); + panel.setBorder(border); + result.add(panel, constraints); + + constraints.anchor = GridBagConstraints.NORTHWEST; + result.add(this.isBoldCheck, constraints); + result.add(this.isItalicCheck, constraints); + result.add(this.useTextAntiAliaseCheck, constraints); + result.add(this.useFractionalCheck, constraints); + + return result; + } + + /** + * プレビュー画面を生成する。 + * @return プレビュー画面 + */ + private JComponent createPreviewPanel(){ + JPanel result = new JPanel(); + + JScrollPane scroller = new JScrollPane(this.preview); + scroller.getVerticalScrollBar().setUnitIncrement(8); + + Border border; + border = BorderFactory.createTitledBorder("プレビュー"); + result.setBorder(border); + result.setLayout(new BorderLayout()); + result.add(scroller, BorderLayout.CENTER); + + return result; + } + + /** + * フォントデコード名表示パネルを生成する。 + * @return フォントデコード名表示パネル + */ + private JComponent createFontDecodePanel(){ + JPanel result = new JPanel(); + + GridBagLayout layout = new GridBagLayout(); + GridBagConstraints constraints = new GridBagConstraints(); + result.setLayout(layout); + + constraints.weightx = 0.0; + constraints.weighty = 0.0; + constraints.gridwidth = 1; + constraints.fill = GridBagConstraints.NONE; + result.add(new JLabel("Font.deode() 識別名:"), constraints); + + constraints.weightx = 1.0; + constraints.gridwidth = GridBagConstraints.REMAINDER; + constraints.fill = GridBagConstraints.HORIZONTAL; + result.add(this.decodeName, constraints); + + return result; + } + + /** + * フォント設定を返す。 + * @return フォント設定 + */ + public FontInfo getFontInfo(){ + return this.fontInfo; + } + + /** + * フォント設定を適用する。 + * @param newInfo 新設定 + * @throws NullPointerException 引数がnull + */ + public void setFontInfo(FontInfo newInfo) throws NullPointerException{ + if(newInfo == null) throw new NullPointerException(); + + FontInfo old = this.fontInfo; + if(old.equals(newInfo)) return; + + this.fontInfo = newInfo; + + updateControlls(); + updatePreview(); + + return; + } + + /** + * 選択されたフォントを返す。 + * @return フォント + */ + private Font getSelectedFont(){ + return this.fontInfo.getFont(); + } + + /** + * 設定されたフォント描画設定を返す。 + * @return 描画設定 + */ + protected FontRenderContext getFontRenderContext(){ + return this.fontInfo.getFontRenderContext(); + } + + /** + * フォント設定に合わせてプレビュー画面を更新する。 + */ + private void updatePreview(){ + this.preview.setFontInfo(this.fontInfo); + return; + } + + /** + * フォント設定に合わせてGUIを更新する。 + */ + private void updateControlls(){ + this.maskListener = true; + + Font currentFont = getSelectedFont(); + FontRenderContext currentContext = getFontRenderContext(); + + String defaultFamily = currentFont.getFamily(); + this.familySelector.setSelectedValue(defaultFamily, true); + + Integer selectedInteger = Integer.valueOf(currentFont.getSize()); + this.sizeSelector.setSelectedItem(selectedInteger); + int sizeItems = this.sizeSelector.getItemCount(); + for(int index = 0; index <= sizeItems - 1; index++){ + Object sizeItem = this.sizeSelector.getItemAt(index); + if(sizeItem.equals(selectedInteger)){ + this.sizeSelector.setSelectedIndex(index); + break; + } + } + + this.isBoldCheck .setSelected(currentFont.isBold()); + this.isItalicCheck.setSelected(currentFont.isItalic()); + + this.useTextAntiAliaseCheck + .setSelected(currentContext.isAntiAliased()); + this.useFractionalCheck + .setSelected(currentContext.usesFractionalMetrics()); + + this.decodeName.setText(FontUtils.getFontDecodeName(currentFont)); + this.decodeName.setCaretPosition(0); + + Rectangle2D r2d = currentFont.getMaxCharBounds(currentContext); + Rectangle rect = r2d.getBounds(); + String boundInfo = "最大文字寸法 : " + + rect.width + + " pixel幅 × " + + rect.height + + " pixel高"; + this.maxBounds.setText(boundInfo); + + this.maskListener = false; + + return; + } + + /** + * {@inheritDoc} + * ダイアログの表示・非表示。 + * ダイアログが閉じられるまで制御を返さない。 + * @param isVisible trueなら表示 {@inheritDoc} + */ + @Override + public void setVisible(boolean isVisible){ + if(isVisible){ + updateControlls(); + updatePreview(); + } + this.lastFontInfo = this.fontInfo; + + super.setVisible(isVisible); + + return; + } + + /** + * {@inheritDoc} + * チェックボックス操作のリスナ。 + * @param event 操作イベント {@inheritDoc} + */ + public void itemStateChanged(ItemEvent event){ + if(this.maskListener) return; + + Object source = event.getSource(); + + if( source != this.isBoldCheck + && source != this.isItalicCheck + && source != this.useTextAntiAliaseCheck + && source != this.useFractionalCheck ){ + return; + } + + int style = 0 | Font.PLAIN; + if(this.isBoldCheck.isSelected()){ + style = style | Font.BOLD; + } + if(this.isItalicCheck.isSelected()){ + style = style | Font.ITALIC; + } + Font newFont = getSelectedFont(); + if(newFont.getStyle() != style){ + newFont = newFont.deriveFont(style); + } + + AffineTransform tx = getFontRenderContext().getTransform(); + boolean isAntiAliases = this.useTextAntiAliaseCheck.isSelected(); + boolean useFractional = this.useFractionalCheck .isSelected(); + FontRenderContext newContext = + new FontRenderContext(tx, isAntiAliases, useFractional); + + FontInfo newInfo = new FontInfo(newFont, newContext); + setFontInfo(newInfo); + + return; + } + + /** + * フォントサイズ変更処理。 + */ + private void actionFontSizeSelected(){ + Object selected = this.sizeSelector.getSelectedItem(); + if(selected == null) return; + + Integer selectedInteger; + if(selected instanceof Integer){ + selectedInteger = (Integer) selected; + }else{ + try{ + selectedInteger = Integer.valueOf(selected.toString()); + }catch(NumberFormatException e){ + selectedInteger = Integer.valueOf( + this.lastFontInfo.getFont().getSize() + ); + } + } + + if(selectedInteger.intValue() <= 0){ + selectedInteger = + Integer.valueOf(this.lastFontInfo.getFont().getSize()); + } + + float fontSize = selectedInteger.floatValue(); + Font newFont = getSelectedFont().deriveFont(fontSize); + FontInfo newInfo = this.fontInfo.deriveFont(newFont); + setFontInfo(newInfo); + + int sizeItems = this.sizeSelector.getItemCount(); + for(int index = 0; index <= sizeItems - 1; index++){ + Object sizeItem = this.sizeSelector.getItemAt(index); + if(sizeItem.equals(selectedInteger)){ + this.sizeSelector.setSelectedIndex(index); + break; + } + } + + updateControlls(); + updatePreview(); + + return; + } + + /** + * {@inheritDoc} + * ボタン操作及びフォントサイズ指定コンボボックス操作のリスナ。 + * @param event 操作イベント {@inheritDoc} + */ + public void actionPerformed(ActionEvent event){ + if(this.maskListener) return; + + String cmd = event.getActionCommand(); + if(cmd.equals(ActionManager.CMD_FONTSIZESEL)){ + actionFontSizeSelected(); + } + + Object source = event.getSource(); + if(source == this.resetDefault){ + setFontInfo(FontInfo.DEFAULT_FONTINFO); + } + + return; + } + + /** + * {@inheritDoc} + * フォントファミリリスト選択操作のリスナ。 + * @param event 操作イベント {@inheritDoc} + */ + public void valueChanged(ListSelectionEvent event){ + if(this.maskListener) return; + + if(event.getSource() != this.familySelector) return; + if(event.getValueIsAdjusting()) return; + + Object selected = this.familySelector.getSelectedValue(); + if(selected == null) return; + + String familyName = selected.toString(); + Font currentFont = getSelectedFont(); + int style = currentFont.getStyle(); + int size = currentFont.getSize(); + + Font newFont = new Font(familyName, style, size); + FontInfo newInfo = this.fontInfo.deriveFont(newFont); + + setFontInfo(newInfo); + + return; + } + + /** + * フォントプレビュー画面用コンポーネント。 + */ + private static class FontPreview extends JComponent{ + + private static final int MARGIN = 5; + + private final GlyphDraw draw; + + private FontInfo fontInfo; + + /** + * コンストラクタ。 + * @param source 文字列 + * @param fontInfo フォント設定 + */ + public FontPreview(CharSequence source, + FontInfo fontInfo ){ + super(); + + this.fontInfo = fontInfo; + this.draw = new GlyphDraw(source, this.fontInfo); + this.draw.setFontInfo(this.fontInfo); + + this.draw.setPos(MARGIN, MARGIN); + + this.draw.setColor(Color.BLACK); + setBackground(Color.WHITE); + + updateBounds(); + + return; + } + + /** + * サイズ更新。 + */ + private void updateBounds(){ + Rectangle bounds = this.draw.setWidth(Integer.MAX_VALUE); + Dimension dimension = new Dimension(bounds.width + MARGIN * 2, + bounds.height + MARGIN * 2 ); + setPreferredSize(dimension); + revalidate(); + repaint(); + return; + } + + /** + * フォント設定の変更。 + * @param newFontInfo フォント設定 + */ + public void setFontInfo(FontInfo newFontInfo){ + this.fontInfo = newFontInfo; + this.draw.setFontInfo(this.fontInfo); + + updateBounds(); + + return; + } + + /** + * {@inheritDoc} + * 文字列の描画。 + * @param g {@inheritDoc} + */ + @Override + public void paintComponent(Graphics g){ + super.paintComponent(g); + Graphics2D g2d = (Graphics2D) g; + this.draw.paint(g2d); + return; + } + } + +} diff --git a/src/main/java/jp/sourceforge/jindolf/FontInfo.java b/src/main/java/jp/sourceforge/jindolf/FontInfo.java new file mode 100644 index 0000000..845ad83 --- /dev/null +++ b/src/main/java/jp/sourceforge/jindolf/FontInfo.java @@ -0,0 +1,260 @@ +/* + * font information + * + * Copyright(c) 2009 olyutorskii + * $Id: FontInfo.java 957 2009-12-14 13:15:37Z olyutorskii $ + */ + +package jp.sourceforge.jindolf; + +import java.awt.Font; +import java.awt.font.FontRenderContext; +import java.awt.font.GlyphVector; +import java.awt.geom.AffineTransform; +import java.text.CharacterIterator; +import jp.sourceforge.jindolf.json.JsBoolean; +import jp.sourceforge.jindolf.json.JsNumber; +import jp.sourceforge.jindolf.json.JsObject; +import jp.sourceforge.jindolf.json.JsPair; +import jp.sourceforge.jindolf.json.JsString; +import jp.sourceforge.jindolf.json.JsValue; + +/** + * フォント描画に関する各種設定。 + */ +public class FontInfo{ + + /** デフォルトのフォント設定。 */ + public static final FontInfo DEFAULT_FONTINFO = new FontInfo(); + + private static final String HASH_FAMILY = "family"; + private static final String HASH_SIZE = "size"; + private static final String HASH_ISBOLD = "isBold"; + private static final String HASH_ISITALIC = "isItalic"; + private static final String HASH_USEAA = "useAntiAlias"; + private static final String HASH_FRACTIONAL = "useFractional"; + + /** + * フォントに応じた最適な描画設定を生成する。 + * @param font フォント + * @return 描画設定 + */ + public static FontRenderContext createBestContext(Font font){ + FontRenderContext result; + + AffineTransform identity = ImtblAffineTx.IDENTITY; + if(FontUtils.guessBitmapFont(font)){ + result = new FontRenderContext(identity, false, false); + }else{ + result = new FontRenderContext(identity, true, true); + } + + return result; + } + + /** + * フォント設定をJSON形式にエンコードする。 + * @param fontInfo フォント設定 + * @return JSON Object + */ + public static JsObject buildJson(FontInfo fontInfo){ + Font font = fontInfo.getFont(); + FontRenderContext frc = fontInfo.getFontRenderContext(); + JsPair type = new JsPair(HASH_FAMILY, + FontUtils.getRootFamilyName(font) ); + JsPair size = new JsPair(HASH_SIZE, font.getSize()); + JsPair bold = new JsPair(HASH_ISBOLD, font.isBold()); + JsPair italic = new JsPair(HASH_ISITALIC, font.isItalic()); + JsPair host = new JsPair(HASH_USEAA, frc.isAntiAliased()); + JsPair port = + new JsPair(HASH_FRACTIONAL, frc.usesFractionalMetrics()); + + JsObject result = new JsObject(); + result.putPair(type); + result.putPair(size); + result.putPair(bold); + result.putPair(italic); + result.putPair(host); + result.putPair(port); + + return result; + } + + /** + * JSONからのフォント設定復元。 + * @param obj JSON Object + * @return フォント設定 + */ + public static FontInfo decodeJson(JsObject obj){ + JsValue value; + + Font newFont = FontUtils.createDefaultSpeechFont(); + FontRenderContext newFrc = createBestContext(newFont); + int style = newFont.getStyle(); + + value = obj.getValue(HASH_FAMILY); + if(value instanceof JsString){ + JsString string = (JsString) value; + Font decoded = Font.decode(string.toRawString()); + if(decoded != null){ + newFont = decoded; + } + } + + int size = newFont.getSize(); + value = obj.getValue(HASH_SIZE); + if(value instanceof JsNumber){ + JsNumber number = (JsNumber) value; + size = number.intValue(); + } + + boolean isBold = newFont.isBold(); + value = obj.getValue(HASH_ISBOLD); + if(value instanceof JsBoolean){ + JsBoolean bool = (JsBoolean) value; + isBold = bool.booleanValue(); + } + if(isBold) style |= Font.BOLD; + + boolean isItalic = newFont.isItalic(); + value = obj.getValue(HASH_ISITALIC); + if(value instanceof JsBoolean){ + JsBoolean bool = (JsBoolean) value; + isItalic = bool.booleanValue(); + } + if(isItalic) style |= Font.ITALIC; + + boolean isAntiAlias = newFrc.isAntiAliased(); + value = obj.getValue(HASH_USEAA); + if(value instanceof JsBoolean){ + JsBoolean bool = (JsBoolean) value; + isAntiAlias = bool.booleanValue(); + } + + boolean useFractional = newFrc.usesFractionalMetrics(); + value = obj.getValue(HASH_FRACTIONAL); + if(value instanceof JsBoolean){ + JsBoolean bool = (JsBoolean) value; + useFractional = bool.booleanValue(); + } + + newFont = newFont.deriveFont(style, (float)size); + + newFrc = new FontRenderContext(ImtblAffineTx.IDENTITY, + isAntiAlias, useFractional); + + FontInfo result = new FontInfo(newFont, newFrc); + + return result; + } + + private Font font; + private FontRenderContext context; + + /** + * コンストラクタ。 + * デフォルトフォントとそれに適した描画属性が指定される。 + */ + public FontInfo(){ + this(FontUtils.createDefaultSpeechFont()); + return; + } + + /** + * コンストラクタ。 + * 描画設定はフォント属性に応じて自動的に調整される。 + * @param font フォント + * @throws NullPointerException 引数がnull + */ + public FontInfo(Font font) + throws NullPointerException{ + this(font, createBestContext(font)); + return; + } + + /** + * コンストラクタ。 + * @param font フォント + * @param context 描画設定 + * @throws NullPointerException 引数がnull + */ + public FontInfo(Font font, FontRenderContext context) + throws NullPointerException{ + super(); + if(font == null || context == null) throw new NullPointerException(); + this.font = font; + this.context = context; + return; + } + + /** + * フォントを返す。 + * @return フォント + */ + public Font getFont(){ + return this.font; + } + + /** + * 描画属性を返す。 + * @return 描画属性 + */ + public FontRenderContext getFontRenderContext(){ + return this.context; + } + + /** + * フォントのみ異なる設定を派生させる。 + * @param newFont 新フォント + * @return 新設定 + */ + public FontInfo deriveFont(Font newFont){ + return new FontInfo(newFont, this.context); + } + + /** + * 描画属性のみ異なる設定を派生させる。 + * @param newContext 新描画設定 + * @return 新設定 + */ + public FontInfo deriveRenderContext(FontRenderContext newContext){ + return new FontInfo(this.font, newContext); + } + + /** + * 文字列からグリフ集合を生成する。 + * @param iterator 文字列 + * @return グリフ集合 + */ + public GlyphVector createGlyphVector(CharacterIterator iterator){ + GlyphVector glyph = + this.font.createGlyphVector(this.context, iterator); + return glyph; + } + + /** + * {@inheritDoc} + * @param obj {@inheritDoc} + * @return {@inheritDoc} + */ + @Override + public boolean equals(Object obj){ + if( ! (obj instanceof FontInfo) ) return false; + FontInfo target = (FontInfo) obj; + + if( ! (this.font .equals(target.font)) ) return false; + if( ! (this.context.equals(target.context)) ) return false; + + return true; + } + + /** + * {@inheritDoc} + * @return {@inheritDoc} + */ + @Override + public int hashCode(){ + return this.font.hashCode() ^ this.context.hashCode(); + } + +} diff --git a/src/main/java/jp/sourceforge/jindolf/FontUtils.java b/src/main/java/jp/sourceforge/jindolf/FontUtils.java new file mode 100644 index 0000000..08358f0 --- /dev/null +++ b/src/main/java/jp/sourceforge/jindolf/FontUtils.java @@ -0,0 +1,158 @@ +/* + * font utilities + * + * Copyright(c) 2009 olyutorskii + * $Id: FontUtils.java 956 2009-12-13 15:14:07Z olyutorskii $ + */ + +package jp.sourceforge.jindolf; + +import java.awt.Font; +import java.awt.GraphicsEnvironment; +import java.util.Collections; +import java.util.Locale; +import java.util.SortedSet; +import java.util.TreeSet; + +/** + * フォントユーティリティ。 + */ +public final class FontUtils{ + + /** Font.DIALOG代替品。 */ + public static final String FAMILY_DIALOG = "Dialog"; + /** Locale.ROOT代替品。 */ + private static final Locale ROOT = new Locale("", "", ""); + + private static final String[] INIT_FAMILY_NAMES = { + "Hiragino Kaku Gothic Pro", // for MacOS X + "Hiragino Kaku Gothic Std", + "Osaka", + "MS PGothic", // for WinXP + "MS Gothic", + // TODO X11用のおすすめは? + }; + + /** JIS0208:1990 チェック用。 */ + private static final String JPCHECK_CODE = "9Aあゑアアヴヰ┼ЖΩ峠凜熙"; + + /** + * システムに存在する有効なファミリ名か判定する。 + * @param family フォントファミリ名。 + * @return 存在する有効なファミリ名ならtrue + */ + public static boolean isValidFamilyName(String family){ + int style = 0x00 | Font.PLAIN; + int size = 1; + Font dummyFont = new Font(family, style, size); + + String dummyFamily = getRootFamilyName(dummyFont); + String dummyLocalFamily = dummyFont.getFamily(); + if(dummyFamily .equals(family)) return true; + if(dummyLocalFamily.equals(family)) return true; + + return false; + } + + /** + * 発言用のデフォルトフォントを生成する。 + * 適当なファミリが見つからなかったら"Dialog"が選択される。 + * @return デフォルトフォント + */ + public static Font createDefaultSpeechFont(){ + String defaultFamilyName = FAMILY_DIALOG; + for(String familyName : INIT_FAMILY_NAMES){ + if(isValidFamilyName(familyName)){ + defaultFamilyName = familyName; + break; + } + } + + int style = 0x00 | Font.PLAIN; + int size = 16; + Font result = new Font(defaultFamilyName, style, size); + + return result; + } + + /** + * ソートされたフォントファミリ一覧表を生成する。 + * JISX0208:1990相当が表示できないファミリは弾かれる。 + * 結構実行時間がかかるかも。乱用禁物。 + * @return フォント一覧 + */ + public static SortedSet createFontSet(){ + GraphicsEnvironment ge = + GraphicsEnvironment.getLocalGraphicsEnvironment(); + + SortedSet result = new TreeSet(); + for(Font font : ge.getAllFonts()){ + if(font.canDisplayUpTo(JPCHECK_CODE) >= 0) continue; + String familyName = font.getFamily(); + result.add(familyName.intern()); + } + + return Collections.unmodifiableSortedSet(result); + } + + /** + * ビットマップフォントか否か見当をつける。 + * ビットマップフォントにはアンチエイリアスやサブピクセルを使わないほうが + * 見栄えがいいような気がする。 + * @param font 判定対象フォント + * @return ビットマップフォントらしかったらtrue + */ + public static boolean guessBitmapFont(Font font){ + String familyName = getRootFamilyName(font); + if( font.getSize() < 24 + && familyName.startsWith("MS") + && ( familyName.contains("Gothic") + || familyName.contains("Mincho") ) ){ + return true; + } + return false; + } + + /** + * Font#decode()用の名前を返す。 + * @param font フォント + * @return Font#decode()用の名前 + */ + public static String getFontDecodeName(Font font){ + StringBuilder result = new StringBuilder(); + + StringBuilder style = new StringBuilder(); + if(font.isBold()) style.append("BOLD"); + if(font.isItalic()) style.append("ITALIC"); + if(style.length() <= 0) style.append("PLAIN"); + + result.append(getRootFamilyName(font)); + result.append('-').append(style); + result.append('-').append(font.getSize()); + + if( result.indexOf("\u0020") >= 0 + || result.indexOf("\u3000") >= 0 ){ + result.insert(0, '"').append('"'); + } + + return result.toString(); + } + + /** + * ロケール中立なフォントファミリ名を返す。 + * JRE1.5対策 + * @param font フォント + * @return ファミリ名 + */ + public static String getRootFamilyName(Font font){ + return font.getFamily(ROOT); + } + + /** + * 隠れコンストラクタ。 + */ + private FontUtils(){ + assert false; + } + +} diff --git a/src/main/java/jp/sourceforge/jindolf/GUIUtils.java b/src/main/java/jp/sourceforge/jindolf/GUIUtils.java new file mode 100644 index 0000000..ac70f5f --- /dev/null +++ b/src/main/java/jp/sourceforge/jindolf/GUIUtils.java @@ -0,0 +1,397 @@ +/* + * GUI utilities + * + * Copyright(c) 2008 olyutorskii + * $Id: GUIUtils.java 971 2009-12-24 16:59:42Z olyutorskii $ + */ + +package jp.sourceforge.jindolf; + +import java.awt.AWTEvent; +import java.awt.Component; +import java.awt.Dialog; +import java.awt.EventQueue; +import java.awt.Frame; +import java.awt.Point; +import java.awt.Rectangle; +import java.awt.RenderingHints; +import java.awt.Toolkit; +import java.awt.Window; +import java.awt.color.ColorSpace; +import java.awt.image.BufferedImage; +import java.awt.image.BufferedImageOp; +import java.awt.image.ColorConvertOp; +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.net.URL; +import javax.imageio.ImageIO; +import javax.swing.BorderFactory; +import javax.swing.Icon; +import javax.swing.ImageIcon; +import javax.swing.JComponent; +import javax.swing.SwingConstants; +import javax.swing.SwingUtilities; +import javax.swing.border.Border; + +/** + * GUI関連のユーティリティクラス。 + */ +public final class GUIUtils{ + + private static final String RES_LOGOICON = + "resources/image/logo.png"; + private static final String RES_WINDOWICON = + "resources/image/winicon.png"; + private static final String RES_WWWICON = + "resources/image/www.png"; + private static final String RES_NOIMAGE = + "resources/image/noimage.png"; + private static BufferedImage logoImage; + private static Icon logoIcon; + private static BufferedImage windowIconImage; + private static Icon wwwIcon; + private static BufferedImage noImage; + + private static final RenderingHints HINTS_QUALITY; + private static final RenderingHints HINTS_SPEEDY; + + private static final BufferedImageOp OP_MONOIMG; + + private static final Runnable TASK_NOTHING = new Runnable(){ + /** 何もしない。 */ + public void run(){} + }; + + static{ + HINTS_QUALITY = new RenderingHints(null); + HINTS_SPEEDY = new RenderingHints(null); + + HINTS_QUALITY.put(RenderingHints.KEY_ANTIALIASING, + RenderingHints.VALUE_ANTIALIAS_ON); + HINTS_SPEEDY.put(RenderingHints.KEY_ANTIALIASING, + RenderingHints.VALUE_ANTIALIAS_OFF); + + HINTS_QUALITY.put(RenderingHints.KEY_RENDERING, + RenderingHints.VALUE_RENDER_QUALITY); + HINTS_SPEEDY.put(RenderingHints.KEY_RENDERING, + RenderingHints.VALUE_RENDER_SPEED); + + HINTS_QUALITY.put(RenderingHints.KEY_DITHERING, + RenderingHints.VALUE_DITHER_ENABLE); + HINTS_SPEEDY.put(RenderingHints.KEY_DITHERING, + RenderingHints.VALUE_DITHER_DISABLE); + + HINTS_QUALITY.put(RenderingHints.KEY_TEXT_ANTIALIASING, + RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + HINTS_SPEEDY.put(RenderingHints.KEY_TEXT_ANTIALIASING, + RenderingHints.VALUE_TEXT_ANTIALIAS_OFF); + + HINTS_QUALITY.put(RenderingHints.KEY_FRACTIONALMETRICS, + RenderingHints.VALUE_FRACTIONALMETRICS_ON); + HINTS_SPEEDY.put(RenderingHints.KEY_FRACTIONALMETRICS, + RenderingHints.VALUE_FRACTIONALMETRICS_OFF); + + HINTS_QUALITY.put(RenderingHints.KEY_INTERPOLATION, + RenderingHints.VALUE_INTERPOLATION_BICUBIC); + HINTS_SPEEDY.put(RenderingHints.KEY_INTERPOLATION, + RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR); + + HINTS_QUALITY.put(RenderingHints.KEY_ALPHA_INTERPOLATION, + RenderingHints.VALUE_ALPHA_INTERPOLATION_QUALITY); + HINTS_SPEEDY.put(RenderingHints.KEY_ALPHA_INTERPOLATION, + RenderingHints.VALUE_ALPHA_INTERPOLATION_SPEED); + + HINTS_QUALITY.put(RenderingHints.KEY_COLOR_RENDERING, + RenderingHints.VALUE_COLOR_RENDER_QUALITY); + HINTS_SPEEDY.put(RenderingHints.KEY_COLOR_RENDERING, + RenderingHints.VALUE_COLOR_RENDER_SPEED); + + HINTS_QUALITY.put(RenderingHints.KEY_STROKE_CONTROL, + RenderingHints.VALUE_STROKE_PURE); + HINTS_SPEEDY.put(RenderingHints.KEY_STROKE_CONTROL, + RenderingHints.VALUE_STROKE_NORMALIZE); + } + + static{ + ColorSpace mono = ColorSpace.getInstance(ColorSpace.CS_GRAY); + OP_MONOIMG = new ColorConvertOp(mono, null); + } + + /** + * 描画品質優先の描画ヒントを返す。 + * @return 描画ヒント + */ + public static RenderingHints getQualityHints(){ + return HINTS_QUALITY; + } + + /** + * リソース名からイメージを取得する。 + * @param resource リソース名 + * @return イメージ + * @throws java.io.IOException 入力エラー + */ + public static BufferedImage loadImageFromResource(String resource) + throws IOException{ + BufferedImage result; + + URL url = Jindolf.getResource(resource); + result = ImageIO.read(url); + + return result; + } + + /** + * ロゴイメージを得る。 + * @return ロゴイメージ + */ + public static BufferedImage getLogoImage(){ + if(logoImage != null){ + return logoImage; + } + + BufferedImage image; + try{ + image = loadImageFromResource(RES_LOGOICON); + }catch(IOException e){ + Jindolf.logger().warn("ロゴイメージの取得に失敗しました", e); + image = new BufferedImage(100, 100, BufferedImage.TYPE_INT_ARGB); + // TODO デカく "狼" とでも描くか? + } + + logoImage = image; + + return logoImage; + } + + /** + * 各種ウィンドウのアイコンイメージを得る。 + * @return アイコンイメージ + */ + public static BufferedImage getWindowIconImage(){ + if(windowIconImage != null){ + return windowIconImage; + } + + BufferedImage image; + try{ + image = loadImageFromResource(RES_WINDOWICON); + }catch(IOException e){ + Jindolf.logger().warn("アイコンイメージの取得に失敗しました", e); + image = getLogoImage(); + } + + windowIconImage = image; + + return windowIconImage; + } + + /** + * ロゴアイコンを得る。 + * @return ロゴアイコン + */ + public static Icon getLogoIcon(){ + if(logoIcon != null){ + return logoIcon; + } + + Icon icon = new ImageIcon(getLogoImage()); + + logoIcon = icon; + + return logoIcon; + } + + /** + * WWWアイコンを得る。 + * @return WWWアイコン + */ + public static Icon getWWWIcon(){ + if(wwwIcon != null){ + return wwwIcon; + } + + URL url = Jindolf.getResource(RES_WWWICON); + wwwIcon = new ImageIcon(url); + + return wwwIcon; + } + + /** + * NoImageイメージを得る。 + * @return NoImageイメージ + */ + public static BufferedImage getNoImage(){ + if(noImage != null){ + return noImage; + } + + URL url = Jindolf.getResource(RES_NOIMAGE); + try{ + noImage = ImageIO.read(url); + }catch(IOException e){ + assert false; + noImage = getLogoImage(); + } + + return noImage; + } + + /** + * AWTディスパッチイベント処理を促す。 + */ + public static void dispatchEmptyAWTEvent(){ + if(SwingUtilities.isEventDispatchThread()){ + return; + } + + try{ + SwingUtilities.invokeAndWait(TASK_NOTHING); + }catch(InterruptedException e){ + // IGNORE + }catch(InvocationTargetException e){ + // IGNORE + } + + return; + } + + /** + * 矩形と点座標の相対関係を判定する。 + * ・矩形に点座標が含まれればSwingContants.CENTER + * ・矩形の上辺より上に点座標が位置すればSwingContants.NORTH + * ・矩形の下辺より下に点座標が位置すればSwingContants.SOUTH + * ・矩形の上辺と下辺内に収まるが右辺からはみ出すときはSwingContants.EAST + * ・矩形の上辺と下辺内に収まるが左辺からはみ出すときはSwingContants.WEST + * @param rect 矩形 + * @param pt 点座標 + * @return 判定結果 + */ + public static int getDirection(Rectangle rect, Point pt){ + if(pt.y < rect.y){ + return SwingConstants.NORTH; + } + if(rect.y + rect.height <= pt.y){ + return SwingConstants.SOUTH; + } + if(pt.x < rect.x){ + return SwingConstants.EAST; + } + if(rect.x + rect.width <= pt.x){ + return SwingConstants.WEST; + } + return SwingConstants.CENTER; + } + + /** + * ウィンドウ属性を設定する。 + * @param window ウィンドウ + * @param isResizable リサイズ可ならtrue + * @param isDynamic リサイズに伴う再描画ならtrue + * @param isAutoLocation 自動位置決め機構を使うならtrue + */ + public static void modifyWindowAttributes(Window window, + boolean isResizable, + boolean isDynamic, + boolean isAutoLocation){ + Toolkit kit = window.getToolkit(); + kit.setDynamicLayout(isDynamic); + + window.setLocationByPlatform(isAutoLocation); + + if(window instanceof Frame){ + Frame frame = (Frame) window; + frame.setIconImage(getWindowIconImage()); + frame.setResizable(isResizable); + }else if(window instanceof Dialog){ + Dialog dialog = (Dialog) window; + dialog.setResizable(isResizable); + } + + return; + } + + /** + * コンポーネントの既存ボーダー内側にマージンをもうける。 + * @param comp 対象コンポーネント + * @param top 上マージン + * @param left 左マージン + * @param bottom 下マージン + * @param right 右マージン + */ + public static void addMargin(Component comp, + int top, int left, int bottom, int right){ + if( ! (comp instanceof JComponent) ) return; + JComponent jcomp = (JComponent) comp; + + Border outer = jcomp.getBorder(); + Border inner = + BorderFactory.createEmptyBorder(top, left, bottom, right); + + Border border; + if(outer == null){ + border = inner; + }else{ + border = BorderFactory.createCompoundBorder(outer, inner); + } + + jcomp.setBorder(border); + + return; + } + + /** + * 独自ロガーにエラーや例外を吐く、 + * カスタム化されたイベントキューに差し替える。 + */ + public static void replaceEventQueue(){ + Toolkit kit = Toolkit.getDefaultToolkit(); + EventQueue oldQueue = kit.getSystemEventQueue(); + EventQueue newQueue = new EventQueue(){ + private static final String FATALMSG = + "イベントディスパッチ中に異常が起きました。"; + @Override + protected void dispatchEvent(AWTEvent event){ + try{ + super.dispatchEvent(event); + }catch(RuntimeException e){ + Jindolf.logger().fatal(FATALMSG, e); + throw e; + }catch(Exception e){ + Jindolf.logger().fatal(FATALMSG, e); + }catch(Error e){ + Jindolf.logger().fatal(FATALMSG, e); + throw e; + } + // TODO Toolkit#beep()もするべきか + // TODO モーダルダイアログを出すべきか + // TODO 標準エラー出力抑止オプションを用意すべきか + // TODO セキュリティバイパス + return; + } + }; + oldQueue.push(newQueue); + return; + } + + /** + * 任意のイメージを多階調モノクロ化する。 + * 寸法は変わらない。 + * @param image イメージ + * @return モノクロ化イメージ + */ + public static BufferedImage createMonoImage(BufferedImage image){ + BufferedImage result; + result = OP_MONOIMG.filter(image, null); + return result; + } + + /** + * 隠れコンストラクタ。 + */ + private GUIUtils(){ + assert false; + throw new AssertionError(); + } + +} diff --git a/src/main/java/jp/sourceforge/jindolf/GameSummary.java b/src/main/java/jp/sourceforge/jindolf/GameSummary.java new file mode 100644 index 0000000..7c54215 --- /dev/null +++ b/src/main/java/jp/sourceforge/jindolf/GameSummary.java @@ -0,0 +1,1081 @@ +/* + * Summarize game information + * + * Copyright(c) 2009 olyutorskii + * $Id: GameSummary.java 1028 2010-05-13 10:15:11Z olyutorskii $ + */ + +package jp.sourceforge.jindolf; + +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.text.DateFormat; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import jp.sourceforge.jindolf.corelib.Destiny; +import jp.sourceforge.jindolf.corelib.GameRole; +import jp.sourceforge.jindolf.corelib.SysEventType; +import jp.sourceforge.jindolf.corelib.Team; +import jp.sourceforge.jindolf.corelib.VillageState; + +/** + * 決着の付いたゲームのサマリを集計。 + */ +public class GameSummary{ + + /** キャスティング表示用Comparator。 */ + public static final Comparator COMPARATOR_CASTING = + new CastingComparator(); + + /** + * プレイヤーのリストから役職バランス文字列を得る。 + * ex) "村村占霊狂狼" + * @param players プレイヤーのリスト + * @return 役職バランス文字列 + */ + public static String getRoleBalanceSequence(List players){ + List roleList = new LinkedList(); + for(Player player : players){ + GameRole role = player.getRole(); + roleList.add(role); + } + Collections.sort(roleList, GameRole.getPowerBalanceComparator()); + + StringBuilder result = new StringBuilder(); + for(GameRole role : roleList){ + char ch = role.getShortName(); + result.append(ch); + } + + return result.toString(); + } + + private final Map playerMap = + new HashMap(); + private final List playerList = + new LinkedList(); + private final Map> eventMap = + new EnumMap>(SysEventType.class); + + private final Village village; + + // 勝者 + private Team winner; + + // 占い先集計 + private int ctScryVillage = 0; + private int ctScryHamster = 0; + private int ctScryMadman = 0; + private int ctScryWolf = 0; + + // 護衛先集計 + private int ctGuardVillage = 0; + private int ctGuardHamster = 0; + private int ctGuardMadman = 0; + private int ctGuardWolf = 0; + private int ctGuardVillageGJ = 0; + private int ctGuardHamsterGJ = 0; + private int ctGuardMadmanGJ = 0; + private int ctGuardFakeGJ = 0; + + // 発言時刻範囲 + private long talk1stTimeMs = -1; + private long talkLastTimeMs = -1; + + /** + * コンストラクタ。 + * @param village 村 + */ + public GameSummary(Village village){ + super(); + + VillageState state = village.getState(); + if( state != VillageState.EPILOGUE + && state != VillageState.GAMEOVER){ + throw new IllegalStateException(); + } + + this.village = village; + + summarize(); + + return; + } + + /** + * サマライズ処理。 + */ + private void summarize(){ + buildEventMap(); + + summarizeTime(); + summarizeWinner(); + summarizePlayers(); + + for(Period period : this.village.getPeriodList()){ + summarizePeriod(period); + } + + summarizeJudge(); + summarizeGuard(); + + return; + } + + /** + * SysEventの種別ごとに集計する。 + */ + private void buildEventMap(){ + for(SysEventType type : SysEventType.values()){ + List eventList = new LinkedList(); + this.eventMap.put(type, eventList); + } + + for(Period period : this.village.getPeriodList()){ + for(Topic topic : period.getTopicList()){ + if( ! (topic instanceof SysEvent) ) continue; + SysEvent event = (SysEvent) topic; + SysEventType type = event.getSysEventType(); + List eventList = this.eventMap.get(type); + eventList.add(event); + } + } + + return; + } + + /** + * 勝者集計。 + */ + private void summarizeWinner(){ + List eventList; + + eventList = this.eventMap.get(SysEventType.WINVILLAGE); + if( ! eventList.isEmpty() ){ + this.winner = Team.VILLAGE; + } + + eventList = this.eventMap.get(SysEventType.WINWOLF); + if( ! eventList.isEmpty() ){ + this.winner = Team.WOLF; + } + + eventList = this.eventMap.get(SysEventType.WINHAMSTER); + if( ! eventList.isEmpty() ){ + this.winner = Team.HAMSTER; + } + + if(this.winner == null) assert false; + + return; + } + + /** + * 参加者集計。 + */ + private void summarizePlayers(){ + List eventList; + + List avatarList; + List roleList; + List integerList; + List textList; + + eventList = this.eventMap.get(SysEventType.ONSTAGE); + for(SysEvent event : eventList){ + avatarList = event.getAvatarList(); + integerList = event.getIntegerList(); + Avatar onstageAvatar = avatarList.get(0); + Player onstagePlayer = registPlayer(onstageAvatar); + onstagePlayer.setEntryNo(integerList.get(0)); + } + + eventList = this.eventMap.get(SysEventType.PLAYERLIST); + assert eventList.size() == 1; + SysEvent event = eventList.get(0); + + avatarList = event.getAvatarList(); + roleList = event.getRoleList(); + integerList = event.getIntegerList(); + textList = event.getCharSequenceList(); + int avatarNum = avatarList.size(); + for(int idx = 0; idx < avatarNum; idx++){ + Avatar avatar = avatarList.get(idx); + GameRole role = roleList.get(idx); + CharSequence urlText = textList.get(idx * 2); + CharSequence idName = textList.get(idx * 2 + 1); + int liveOrDead = integerList.get(idx); + + Player player = registPlayer(avatar); + player.setRole(role); + player.setUrlText(urlText.toString()); + player.setIdName(idName.toString()); + if(liveOrDead != 0){ // 生存 + player.setObitDay(-1); + player.setDestiny(Destiny.ALIVE); + } + + this.playerList.add(player); + } + + return; + } + + /** + * Periodのサマライズ。 + * @param period Period + */ + private void summarizePeriod(Period period){ + int day = period.getDay(); + for(Topic topic : period.getTopicList()){ + if(topic instanceof SysEvent){ + SysEvent sysEvent = (SysEvent) topic; + summarizeDestiny(day, sysEvent); + } + } + + return; + } + + /** + * 各プレイヤー運命のサマライズ。 + * @param day 日 + * @param sysEvent システムイベント + */ + private void summarizeDestiny(int day, SysEvent sysEvent){ + List avatarList = sysEvent.getAvatarList(); + List integerList = sysEvent.getIntegerList(); + + int avatarTotal = avatarList.size(); + Avatar lastAvatar = null; + if(avatarTotal > 0) lastAvatar = avatarList.get(avatarTotal - 1); + + SysEventType eventType = sysEvent.getSysEventType(); + switch(eventType){ + case EXECUTION: // G国のみ + if(integerList.get(avatarTotal - 1) > 0) break; // 処刑無し + Player executedPl = registPlayer(lastAvatar); + executedPl.setDestiny(Destiny.EXECUTED); + executedPl.setObitDay(day); + break; + case SUDDENDEATH: + Avatar suddenDeathAvatar = avatarList.get(0); + Player suddenDeathPlayer = registPlayer(suddenDeathAvatar); + suddenDeathPlayer.setDestiny(Destiny.SUDDENDEATH); + suddenDeathPlayer.setObitDay(day); + break; + case COUNTING: // G国COUNTING2は運命に関係なし + if(avatarTotal % 2 == 0) break; // 処刑無し + Player executedPlayer = registPlayer(lastAvatar); + executedPlayer.setDestiny(Destiny.EXECUTED); + executedPlayer.setObitDay(day); + break; + case MURDERED: + for(Avatar avatar : avatarList){ + Player player = registPlayer(avatar); + player.setDestiny(Destiny.EATEN); + player.setObitDay(day); + } + // TODO E国ハム溶け処理は後回し + break; + default: + break; + } + + return; + } + + /** + * 会話時刻のサマライズ。 + */ + private void summarizeTime(){ + for(Period period : this.village.getPeriodList()){ + for(Topic topic : period.getTopicList()){ + if( ! (topic instanceof Talk) ) continue; + Talk talk = (Talk) topic; + + long epoch = talk.getTimeFromID(); + + if(this.talk1stTimeMs < 0) this.talk1stTimeMs = epoch; + if(this.talkLastTimeMs < 0) this.talkLastTimeMs = epoch; + + if(epoch < this.talk1stTimeMs ) this.talk1stTimeMs = epoch; + if(epoch > this.talkLastTimeMs) this.talkLastTimeMs = epoch; + } + } + + return; + } + + /** + * 占い師の活動を集計する。 + */ + private void summarizeJudge(){ + List eventList = this.eventMap.get(SysEventType.JUDGE); + + for(SysEvent event : eventList){ + List avatarList = event.getAvatarList(); + Avatar avatar = avatarList.get(1); + Player seered = getPlayer(avatar); + GameRole role = seered.getRole(); + switch(role){ + case WOLF: this.ctScryWolf++; break; + case MADMAN: this.ctScryMadman++; break; + case HAMSTER: this.ctScryHamster++; break; + default: this.ctScryVillage++; break; + } + } + + return; + } + + /** + * 占い師の活動を文字列化する。 + * @return 占い師の活動 + */ + public CharSequence dumpSeerActivity(){ + StringBuilder result = new StringBuilder(); + + if(this.ctScryVillage > 0){ + result.append("村陣営を"); + result.append(this.ctScryVillage); + result.append("回"); + } + + if(this.ctScryHamster > 0){ + if(result.length() > 0) result.append('、'); + result.append("ハムスターを"); + result.append(this.ctScryHamster); + result.append("回"); + } + + if(this.ctScryMadman > 0){ + if(result.length() > 0) result.append('、'); + result.append("狂人を"); + result.append(this.ctScryMadman); + result.append("回"); + } + + if(this.ctScryWolf > 0){ + if(result.length() > 0) result.append('、'); + result.append("人狼を"); + result.append(this.ctScryWolf); + result.append("回"); + } + + if(result.length() <= 0) result.append("誰も占わなかった。"); + else result.append("占った。"); + + CharSequence seq = WolfBBS.escapeWikiSyntax(result); + + return seq; + } + + /** + * 狩人の活動を集計する。 + */ + private void summarizeGuard(){ + List eventList; + + eventList = this.eventMap.get(SysEventType.GUARD); + for(SysEvent event : eventList){ + List avatarList = event.getAvatarList(); + Avatar avatar = avatarList.get(1); + Player guarded = getPlayer(avatar); + GameRole guardedRole = guarded.getRole(); + switch(guardedRole){ + case WOLF: this.ctGuardWolf++; break; + case MADMAN: this.ctGuardMadman++; break; + case HAMSTER: this.ctGuardHamster++; break; + default: this.ctGuardVillage++; break; + } + } + + for(Period period : this.village.getPeriodList()){ + summarizeGjPeriod(period); + } + + return; + } + + /** + * 狩人GJの日ごとの集計。 + * @param period 日 + */ + private void summarizeGjPeriod(Period period){ + if(period.getDay() <= 2) return; + + boolean hasAssaultTried = period.hasAssaultTried(); + boolean hunterAlive = false; + int wolfNum = 0; + + Set voters = period.getVoterSet(); + for(Avatar avatar : voters){ + Player player = getPlayer(avatar); + switch(player.getRole()){ + case HUNTER: hunterAlive = true; break; + case WOLF: wolfNum++; break; + default: break; + } + } + + Avatar executed = period.getExecutedAvatar(); + if(executed != null){ + Player player = getPlayer(executed); + switch(player.getRole()){ + case HUNTER: hunterAlive = false; break; + case WOLF: wolfNum--; break; + default: break; + } + } + + if( ! hunterAlive || wolfNum <= 0) return; + + SysEvent sysEvent; + + sysEvent = period.getTypedSysEvent(SysEventType.NOMURDER); + if(sysEvent == null) return; + + sysEvent = period.getTypedSysEvent(SysEventType.GUARD); + if(sysEvent == null) return; + + if(hasAssaultTried){ + Avatar guarded = sysEvent.getAvatarList().get(1); + Player guardedPlayer = getPlayer(guarded); + GameRole guardedRole = guardedPlayer.getRole(); + switch(guardedRole){ + case MADMAN: this.ctGuardMadmanGJ++; break; + case HAMSTER: this.ctGuardHamsterGJ++; break; + default: this.ctGuardVillageGJ++; break; + } + }else{ + this.ctGuardFakeGJ++; // 偽装GJ + } + + return; + } + + /** + * 狩人の活動を文字列化する。 + * @return 狩人の活動 + */ + public CharSequence dumpHunterActivity(){ + StringBuilder result = new StringBuilder(); + + String atLeast; + if(this.ctGuardFakeGJ > 0) atLeast = "少なくとも"; + else atLeast = ""; + + if(this.ctGuardVillage > 0){ + result.append(atLeast); + result.append("村陣営を"); + result.append(this.ctGuardVillage); + result.append("回護衛し"); + if(this.ctGuardVillageGJ > 0){ + result.append("GJを"); + result.append(this.ctGuardVillageGJ); + result.append("回出した。"); + }else{ + result.append("た。"); + } + } + + if(this.ctGuardHamster > 0){ + result.append(atLeast); + result.append("ハムスターを"); + result.append(this.ctGuardHamster); + result.append("回護衛し"); + if(this.ctGuardHamsterGJ > 0){ + result.append("GJを"); + result.append(this.ctGuardHamsterGJ); + result.append("回出した。"); + }else{ + result.append("た。"); + } + } + + if(this.ctGuardMadman > 0){ + result.append(atLeast); + result.append("狂人を"); + result.append(this.ctGuardMadman); + result.append("回護衛し"); + if(this.ctGuardMadmanGJ > 0){ + result.append("GJを"); + result.append(this.ctGuardMadmanGJ); + result.append("回出した。"); + }else{ + result.append("た。"); + } + } + + if(this.ctGuardWolf > 0){ + result.append(atLeast); + result.append("人狼を"); + result.append(this.ctGuardWolf); + result.append("回護衛した。"); + } + + if(this.ctGuardFakeGJ > 0){ + result.append("護衛先は不明ながら偽装GJが"); + result.append(this.ctGuardFakeGJ); + result.append("回あった。"); + } + + if(result.length() <= 0) result.append("誰も護衛できなかった"); + + CharSequence seq = WolfBBS.escapeWikiSyntax(result); + + return seq; + } + + /** + * 処刑概観を文字列化する。 + * @return 文字列化した処刑概観 + */ + public CharSequence dumpExecutionInfo(){ + StringBuilder result = new StringBuilder(); + + int exeWolf = 0; + int exeMad = 0; + int exeVillage = 0; + for(Player player : this.playerList){ + Destiny destiny = player.getDestiny(); + if(destiny != Destiny.EXECUTED) continue; + GameRole role = player.getRole(); + switch(role){ + case WOLF: exeWolf++; break; + case MADMAN: exeMad++; break; + default: exeVillage++; break; + } + } + + if(exeVillage > 0){ + result.append("▼村陣営×").append(exeVillage).append("回"); + } + if(exeMad > 0){ + if(result.length() > 0) result.append("、"); + result.append("▼狂×").append(exeMad).append("回"); + } + if(exeWolf > 0){ + if(result.length() > 0) result.append("、"); + result.append("▼狼×").append(exeWolf).append("回"); + } + if(result.length() <= 0) result.append("なし"); + + CharSequence seq = WolfBBS.escapeWikiSyntax(result); + + return seq; + } + + /** + * 襲撃概観を文字列化する。 + * @return 文字列化した襲撃概観 + */ + public CharSequence dumpAssaultInfo(){ + StringBuilder result = new StringBuilder(); + + int eatMad = 0; + int eatVillage = 0; + for(Player player : this.playerList){ + if(player.getAvatar() == Avatar.AVATAR_GERD){ + result.append("▲ゲルト"); + continue; + } + Destiny destiny = player.getDestiny(); + if(destiny != Destiny.EATEN) continue; + GameRole role = player.getRole(); + switch(role){ + case MADMAN: eatMad++; break; + default: eatVillage++; break; + } + } + + if(eatVillage > 0){ + if(result.length() > 0) result.append("、"); + result.append("▲村陣営×").append(eatVillage).append("回"); + } + if(eatMad > 0){ + if(result.length() > 0) result.append("、"); + result.append("▲狂×").append(eatMad).append("回"); + } + + if(result.length() <= 0) result.append("襲撃なし"); + + CharSequence seq = WolfBBS.escapeWikiSyntax(result); + + return seq; + } + + /** + * まとめサイト用投票Boxを生成する。 + * @return 投票BoxのWikiテキスト + */ + public CharSequence dumpVoteBox(){ + StringBuilder wikiText = new StringBuilder(); + + for(Player player : getCastingPlayerList()){ + Avatar avatar = player.getAvatar(); + if(avatar == Avatar.AVATAR_GERD) continue; + GameRole role = player.getRole(); + CharSequence fullName = avatar.getFullName(); + CharSequence roleName = role.getRoleName(); + StringBuilder line = new StringBuilder(); + line.append("[").append(roleName).append("] ").append(fullName); + if(wikiText.length() > 0) wikiText.append(','); + wikiText.append(WolfBBS.escapeWikiSyntax(line)); + wikiText.append("[0]"); + } + + wikiText.insert(0, "#vote(").append(")\n"); + + return wikiText; + } + + /** + * まとめサイト用キャスト表を生成する。 + * @param iconSet 顔アイコンセット + * @return キャスト表のWikiテキスト + */ + public CharSequence dumpCastingBoard(FaceIconSet iconSet){ + StringBuilder wikiText = new StringBuilder(); + + String vName = this.village.getVillageFullName(); + String generator = Jindolf.TITLE + " Ver." + Jindolf.VERSION; + String author = iconSet.getAuthor() + "氏" + +" [ "+iconSet.getUrlText()+" ]"; + + wikiText.append(WolfBBS.COMMENTLINE); + wikiText.append("// ↓キャスト表開始\n"); + wikiText.append("// Village : " + vName + "\n"); + wikiText.append("// Generator : " + generator + "\n"); + wikiText.append("// アイコン作者 : " + author + '\n'); + wikiText.append("// ※アイコン画像の著作財産権保持者" + +"および画像サーバ運営者から\n"); + wikiText.append("// 新しい意向が示された場合、" + +"そちらを最優先で尊重してください。\n"); + wikiText.append(WolfBBS.COMMENTLINE); + + wikiText.append("|配役") + .append("|参加者") + .append("|役職") + .append("|運命") + .append("|その活躍") + .append("|h") + .append('\n'); + wikiText.append(WolfBBS.COMMENTLINE); + + for(Player player : getCastingPlayerList()){ + Avatar avatar = player.getAvatar(); + GameRole role = player.getRole(); + Destiny destiny = player.getDestiny(); + int obitDay = player.getObitDay(); + String name = player.getIdName(); + String urlText = player.getUrlText(); + if(urlText == null) urlText = ""; + urlText = urlText.replace("~", "%7e"); + urlText = urlText.replace(" ", "%20"); + try{ + URL url = new URL(urlText); + URI uri = url.toURI(); + urlText = uri.toASCIIString(); + }catch(MalformedURLException e){ + // NOTHING + }catch(URISyntaxException e){ + // NOTHING + } + // PukiWikiではURL内の&のエスケープは不要? + + wikiText.append("// ========== "); + wikiText.append(name + " acts as [" + avatar.getName() + "]"); + wikiText.append(" ==========\n"); + + String teamColor = "BGCOLOR(" + + WolfBBS.getTeamWikiColor(role) + + "):"; + + String avatarIcon = iconSet.getAvatarIconWiki(avatar); + + wikiText.append('|').append(teamColor); + wikiText.append(avatarIcon).append("&br;"); + + wikiText.append("[[").append(avatar.getName()).append("]]"); + + wikiText.append('|').append(teamColor); + wikiText.append("[[").append(WolfBBS.escapeWikiBracket(name)); + if(urlText != null && urlText.length() > 0){ + wikiText.append('>').append(urlText); + } + wikiText.append("]]"); + + wikiText.append('|').append(teamColor); + wikiText.append(WolfBBS.getRoleIconWiki(role)); + wikiText.append("&br;"); + wikiText.append("[["); + wikiText.append(role.getRoleName()); + wikiText.append("]]"); + + String destinyColor = WolfBBS.getDestinyColorWiki(destiny); + wikiText.append('|'); + wikiText.append("BGCOLOR(").append(destinyColor).append("):"); + if(destiny == Destiny.ALIVE){ + wikiText.append("最後まで&br;生存"); + }else{ + wikiText.append(obitDay).append("日目").append("&br;"); + wikiText.append(destiny.getMessage()); + } + + wikiText.append('|'); + wikiText.append(avatar.getJobTitle()).append('。'); + + if(avatar == Avatar.AVATAR_GERD){ + wikiText.append("寝てばかりいた。"); + }else if(role == GameRole.HUNTER){ + CharSequence report = dumpHunterActivity(); + wikiText.append(report); + }else if(role == GameRole.SEER){ + CharSequence report = dumpSeerActivity(); + wikiText.append(report); + } + + wikiText.append("|\n"); + + } + + wikiText.append("|>|>|>|>|"); + wikiText.append("RIGHT:"); + wikiText.append("顔アイコン提供 : [["); + wikiText.append(WolfBBS.escapeWikiBracket(iconSet.getAuthor())); + wikiText.append(">" + iconSet.getUrlText()); + wikiText.append("]]氏"); + wikiText.append("|\n"); + + wikiText.append(WolfBBS.COMMENTLINE); + wikiText.append("// ↑キャスト表ここまで\n"); + wikiText.append(WolfBBS.COMMENTLINE); + + return wikiText; + } + + /** + * 村詳細情報を出力する。 + * @return 村詳細情報 + */ + public CharSequence dumpVillageWiki(){ + StringBuilder wikiText = new StringBuilder(); + + DateFormat dform = + DateFormat.getDateTimeInstance(DateFormat.FULL, + DateFormat.FULL); + + String vName = this.village.getVillageFullName(); + String generator = Jindolf.TITLE + " Ver." + Jindolf.VERSION; + + wikiText.append(WolfBBS.COMMENTLINE); + wikiText.append("// ↓村詳細開始\n"); + wikiText.append("// Village : " + vName + "\n"); + wikiText.append("// Generator : " + generator + "\n"); + + wikiText.append("* 村の詳細\n"); + + wikiText.append(WolfBBS.COMMENTLINE); + wikiText.append("- 勝者\n"); + Team winnerTeam = getWinnerTeam(); + String wonTeam = winnerTeam.getTeamName(); + wikiText.append(wonTeam).append('\n'); + + wikiText.append(WolfBBS.COMMENTLINE); + wikiText.append("- エントリー開始時刻\n"); + Date date = get1stTalkDate(); + String talk1st = dform.format(date); + wikiText.append(talk1st).append('\n'); + + wikiText.append(WolfBBS.COMMENTLINE); + wikiText.append("- 参加人数\n"); + int avatarNum = countAvatarNum(); + String totalMember = "ゲルト + " + (avatarNum - 1) + "名 = " + + avatarNum + "名"; + wikiText.append(WolfBBS.escapeWikiSyntax(totalMember)) + .append('\n'); + + wikiText.append(WolfBBS.COMMENTLINE); + wikiText.append("- 役職内訳\n"); + StringBuilder roleMsg = new StringBuilder(); + for(GameRole role : GameRole.values()){ + List players = getRoledPlayerList(role); + String roleName = role.getRoleName(); + if(players.size() <= 0) continue; + if(roleMsg.length() > 0) roleMsg.append('、'); + roleMsg.append(roleName) + .append(" × ") + .append(players.size()) + .append("名"); + } + wikiText.append(WolfBBS.escapeWikiSyntax(roleMsg)).append('\n'); + + wikiText.append(WolfBBS.COMMENTLINE); + wikiText.append("- 処刑内訳\n"); + wikiText.append(dumpExecutionInfo()).append('\n'); + + wikiText.append(WolfBBS.COMMENTLINE); + wikiText.append("- 襲撃内訳\n"); + wikiText.append(dumpAssaultInfo()).append('\n'); + + wikiText.append(WolfBBS.COMMENTLINE); + wikiText.append("- 突然死\n"); + wikiText.append(countSuddenDeath()).append("名").append('\n'); + + wikiText.append(WolfBBS.COMMENTLINE); + wikiText.append("- 人口推移\n"); + for(int day = 1; day < this.village.getPeriodSize(); day++){ + List players = getSurvivorList(day); + CharSequence roleSeq = + GameSummary.getRoleBalanceSequence(players); + String daySeq; + Period period = this.village.getPeriod(day); + daySeq = period.getCaption(); + wikiText.append('|') + .append(daySeq) + .append('|') + .append(roleSeq) + .append("|\n"); + } + + wikiText.append(WolfBBS.COMMENTLINE); + wikiText.append("- 占い師の成績\n"); + wikiText.append(dumpSeerActivity()).append('\n'); + + wikiText.append(WolfBBS.COMMENTLINE); + wikiText.append("- 狩人の成績\n"); + wikiText.append(dumpHunterActivity()).append('\n'); + + wikiText.append(WolfBBS.COMMENTLINE); + wikiText.append("// ↑村詳細ここまで\n"); + wikiText.append(WolfBBS.COMMENTLINE); + + return wikiText; + } + + /** + * 最初の発言の時刻を得る。 + * @return 時刻 + */ + public Date get1stTalkDate(){ + return new Date(this.talk1stTimeMs); + } + + /** + * 最後の発言の時刻を得る。 + * @return 時刻 + */ + public Date getLastTalkDate(){ + return new Date(this.talkLastTimeMs); + } + + /** + * 指定した日の生存者一覧を得る。 + * @param day 日 + * @return 生存者一覧 + */ + public List getSurvivorList(int day){ + if(day < 0 || this.village.getPeriodSize() <= day){ + throw new IndexOutOfBoundsException(); + } + + List result = new LinkedList(); + + Period period = this.village.getPeriod(day); + + if( period.isPrologue() + || (period.isProgress() && day == 1) ){ + result.addAll(this.playerList); + return result; + } + + if(period.isEpilogue()){ + for(Player player : this.playerList){ + if(player.getDestiny() == Destiny.ALIVE){ + result.add(player); + } + } + return result; + } + + for(Topic topic : period.getTopicList()){ + if( ! (topic instanceof SysEvent) ) continue; + SysEvent sysEvent = (SysEvent) topic; + if(sysEvent.getSysEventType() == SysEventType.SURVIVOR){ + List avatarList = sysEvent.getAvatarList(); + for(Avatar avatar : avatarList){ + Player player = getPlayer(avatar); + result.add(player); + } + } + } + + return result; + } + + /** + * プレイヤー一覧を得る。 + * 参加エントリー順 + * @return プレイヤーのリスト + */ + public List getPlayerList(){ + List result = Collections.unmodifiableList(this.playerList); + return result; + } + + /** + * キャスティング表用にソートされたプレイヤー一覧を得る。 + * @return プレイヤーのリスト + */ + public List getCastingPlayerList(){ + List sortedPlayers = + new LinkedList(); + sortedPlayers.addAll(this.playerList); + Collections.sort(sortedPlayers, COMPARATOR_CASTING); + return sortedPlayers; + } + + /** + * 指定された役職のプレイヤー一覧を得る。 + * @param role 役職 + * @return 役職に合致するプレイヤーのリスト + */ + public List getRoledPlayerList(GameRole role){ + List result = new LinkedList(); + + for(Player player : this.playerList){ + if(player.getRole() == role){ + result.add(player); + } + } + + return result; + } + + /** + * 勝利陣営を得る。 + * @return 勝利した陣営 + */ + public Team getWinnerTeam(){ + return this.winner; + } + + /** + * 突然死者数を得る。 + * @return 突然死者数 + */ + public int countSuddenDeath(){ + int suddenDeath = 0; + for(Player player : this.playerList){ + if(player.getDestiny() == Destiny.SUDDENDEATH) suddenDeath++; + } + return suddenDeath; + } + + /** + * 参加プレイヤー総数を得る。 + * @return プレイヤー総数 + */ + public int countAvatarNum(){ + int playerNum = this.playerList.size(); + return playerNum; + } + + /** + * AvatarからPlayerを得る。 + * 参加していないAvatarならnullを返す。 + * @param avatar Avatar + * @return Player + */ + public final Player getPlayer(Avatar avatar){ + Player player = this.playerMap.get(avatar); + return player; + } + + /** + * AvatarからPlayerを得る。 + * 無ければ新規に作る。 + * @param avatar Avatar + * @return Player + */ + private Player registPlayer(Avatar avatar){ + Player player = getPlayer(avatar); + if(player == null){ + player = new Player(); + player.setAvatar(avatar); + this.playerMap.put(avatar, player); + } + return player; + } + + /** + * プレイヤーのソート仕様の記述。 + * まとめサイトのキャスト表向け。 + */ + private static final class CastingComparator + implements Comparator { + + /** + * コンストラクタ。 + */ + private CastingComparator(){ + super(); + return; + } + + /** + * {@inheritDoc} + * @param p1 {@inheritDoc} + * @param p2 {@inheritDoc} + * @return {@inheritDoc} + */ + public int compare(Player p1, Player p2){ + if(p1 == p2) return 0; + if(p1 == null) return -1; + if(p2 == null) return +1; + + // ゲルトが最前 + Avatar avatar1 = p1.getAvatar(); + Avatar avatar2 = p2.getAvatar(); + if(avatar1.equals(avatar2)) return 0; + if(avatar1 == Avatar.AVATAR_GERD) return -1; + if(avatar2 == Avatar.AVATAR_GERD) return +1; + + // 生存者は最後 + Destiny dest1 = p1.getDestiny(); + Destiny dest2 = p2.getDestiny(); + if(dest1 != dest2){ + if (dest1 == Destiny.ALIVE) return +1; + else if(dest2 == Destiny.ALIVE) return -1; + } + + // 退場順 + int obitDay1 = p1.getObitDay(); + int obitDay2 = p2.getObitDay(); + if(obitDay1 > obitDay2) return +1; + if(obitDay1 < obitDay2) return -1; + + // 運命順 + int destinyOrder = dest1.compareTo(dest2); + if(destinyOrder != 0) return destinyOrder; + + // エントリー順 + int entryOrder = p1.getEntryNo() - p2.getEntryNo(); + + return entryOrder; + } + } + + // TODO E国ハムスター対応 +} diff --git a/src/main/java/jp/sourceforge/jindolf/GlyphDraw.java b/src/main/java/jp/sourceforge/jindolf/GlyphDraw.java new file mode 100644 index 0000000..f55c78f --- /dev/null +++ b/src/main/java/jp/sourceforge/jindolf/GlyphDraw.java @@ -0,0 +1,765 @@ +/* + * Text-Glyph Drawing + * + * Copyright(c) 2008 olyutorskii + * $Id: GlyphDraw.java 959 2009-12-14 14:11:01Z olyutorskii $ + */ + +package jp.sourceforge.jindolf; + +import java.awt.Color; +import java.awt.FontMetrics; +import java.awt.Graphics2D; +import java.awt.Point; +import java.awt.Rectangle; +import java.awt.Shape; +import java.awt.font.GlyphVector; +import java.awt.geom.Rectangle2D; +import java.io.IOException; +import java.text.CharacterIterator; +import java.util.Collection; +import java.util.LinkedList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import javax.swing.SwingConstants; + +/** + * 複数行の文字列を矩形内に描画する。 + * 制御文字は'\n'のみサポート。 + */ +public class GlyphDraw extends AbstractTextRow implements SwingConstants{ + + private static final Color COLOR_SELECTION = new Color(0xb8cfe5); + private static final Color COLOR_SEARCHHIT = new Color(0xb2b300); + private static final Color COLOR_HOTTARGET = Color.ORANGE; + + private Color foregroundColor = Color.WHITE; + private final CharSequence source; + + private float[] dimArray; + private final List lines = new LinkedList(); + private Collection anchorSet; + private final List matchList = new LinkedList(); + private MatchInfo hotTarget = null; + + private int selectStart = -1; + private int selectLast = -1; + + /** + * コンストラクタ。 + * @param source 文字列 + */ + public GlyphDraw(CharSequence source){ + this(source, FontInfo.DEFAULT_FONTINFO); + return; + } + + /** + * コンストラクタ。 + * @param source 文字列 + * @param fontInfo フォント設定 + */ + public GlyphDraw(CharSequence source, FontInfo fontInfo){ + super(fontInfo); + + this.source = source; + + GlyphVector gv = createGlyphVector(this.source); + + int sourceLength = gv.getNumGlyphs(); + + this.dimArray = gv.getGlyphPositions(0, sourceLength+1, null); + + return; + } + + /** + * 前景色を得る。 + * @return 前景色 + */ + public Color getColor(){ + return this.foregroundColor; + } + + /** + * 前景色を設定する。 + * @param color 前景色 + */ + public void setColor(Color color){ + this.foregroundColor = color; + return; + } + + /** + * アンカーを設定する。 + * アンカーの位置指定はコンストラクタに与えた文字列に対するものでなければ + * ならない。 + * @param anchorSet アンカーの集合 + */ + public void setAnchorSet(Collection anchorSet){ + this.anchorSet = anchorSet; + return; + } + + /** + * 文字列の占めるピクセル幅を返す。 + * @param fromPos 文字列開始位置 + * @param toPos 文字列終了位置 + * @return ピクセル幅 + */ + public float getSpan(int fromPos, int toPos){ + float from = this.dimArray[fromPos * 2]; + float to = this.dimArray[(toPos+1) * 2]; + float span = to - from; + return span; + } + + /** + * 指定領域の文字列から行情報を生成し内部に登録する。 + * @param from 文字列開始位置 + * @param to 文字列終了位置 + * @return 行情報 + */ + protected GlyphVector createLine(int from, int to){ + GlyphVector line = createGlyphVector(this.source, from, to + 1); + this.lines.add(line); + return line; + } + + /** + * {@inheritDoc} + * @return {@inheritDoc} + */ + // TODO 最後が \n で終わるダイアログが無限再帰を起こす? + public Rectangle recalcBounds(){ + float newWidth = (float) getWidth(); + this.lines.clear(); + CharacterIterator iterator; + iterator = new SequenceCharacterIterator(this.source); + int from = iterator.getIndex(); + int to = from; + for(;;){ + char ch = iterator.current(); + + if(ch == CharacterIterator.DONE){ + if(from < to){ + createLine(from, to - 1); + } + break; + } + + if(ch == '\n'){ + createLine(from, to); + to++; + from = to; + iterator.next(); + continue; + } + + float fwidth = getSpan(from, to); + if(fwidth > newWidth){ + if(from < to){ + createLine(from, to - 1); + from = to; + }else{ + createLine(from, to); + to++; + from = to; + iterator.next(); + } + continue; + } + + to++; + iterator.next(); + } + + int totalWidth = 0; + int totalHeight = 0; + for(GlyphVector gv : this.lines){ + Rectangle2D r2d = gv.getLogicalBounds(); + Rectangle rect = r2d.getBounds(); + totalWidth = Math.max(totalWidth, rect.width); + totalHeight += rect.height; + } + + this.bounds.width = totalWidth; + this.bounds.height = totalHeight; + + return this.bounds; + } + + /** + * {@inheritDoc} + * @param fontInfo {@inheritDoc} + */ + @Override + public void setFontInfo(FontInfo fontInfo){ + super.setFontInfo(fontInfo); + + GlyphVector gv = createGlyphVector(this.source); + + int sourceLength = gv.getNumGlyphs(); + + this.dimArray = gv.getGlyphPositions(0, sourceLength+1, null); + + recalcBounds(); + + return; + } + + /** + * 指定された点座標が文字列のどこを示すか判定する。 + * @param pt 点座標 + * @return 文字位置。座標が文字列以外を示す場合は-1を返す。 + */ + public int getCharIndex(Point pt){ + if( ! this.bounds.contains(pt) ) return -1; + + int sPos = 0; + int xPos = this.bounds.x; + int yPos = this.bounds.y; + for(GlyphVector gv : this.lines){ + Rectangle2D r2d = gv.getLogicalBounds(); + Rectangle rect = r2d.getBounds(); + rect.x = xPos; + rect.y = yPos; + int sourceLength = gv.getNumGlyphs(); + if(rect.contains(pt)){ + for(int pos = 0; pos < sourceLength; pos++){ + float span = getSpan(sPos, sPos+pos); + if(span+xPos > pt.x) return sPos + pos; + } + return -1; + } + yPos += rect.height; + sPos += sourceLength; + } + + return -1; + } + + /** + * {@inheritDoc} + * @param appendable {@inheritDoc} + * @return {@inheritDoc} + * @throws java.io.IOException {@inheritDoc} + */ + public Appendable appendSelected(Appendable appendable) + throws IOException{ + if(this.selectStart < 0 || this.selectLast < 0) return appendable; + CharSequence subsel; + subsel = this.source.subSequence(this.selectStart, + this.selectLast + 1); + appendable.append(subsel); + return appendable; + } + + /** + * {@inheritDoc} + */ + public void clearSelect(){ + this.selectStart = -1; + this.selectLast = -1; + return; + } + + /** + * 指定した部分文字列を選択された状態にする。 + * @param start 文字列開始位置 + * @param last 文字列終了位置 + */ + public void select(int start, int last){ + if(start < last){ + this.selectStart = start; + this.selectLast = last; + }else{ + this.selectStart = last; + this.selectLast = start; + } + this.selectLast = Math.min(this.source.length() - 1, + this.selectLast ); + return; + } + + /** + * {@inheritDoc} + * @param from {@inheritDoc} + * @param to {@inheritDoc} + */ + public void drag(Point from, Point to){ + Point fromPt = from; + Point toPt = to; + if(fromPt.y > toPt.y || (fromPt.y == toPt.y && fromPt.x > toPt.x)){ + Point swapPt = fromPt; + fromPt = toPt; + toPt = swapPt; + } + + int fromDirection = GUIUtils.getDirection(this.bounds, fromPt); + int toDirection = GUIUtils.getDirection(this.bounds, toPt); + + if(fromDirection == toDirection){ + if( fromDirection == NORTH + || fromDirection == SOUTH){ + clearSelect(); + return; + } + } + + int fromIndex = -1; + int toIndex = -1; + + if(fromDirection == NORTH){ + fromIndex = 0; + } + if(toDirection == SOUTH){ + toIndex = this.source.length() - 1; + } + + if(fromIndex < 0){ + fromIndex = getCharIndex(fromPt); + } + if(toIndex < 0){ + toIndex = getCharIndex(toPt); + } + + if(fromIndex >= 0 && toIndex >= 0){ + select(fromIndex, toIndex); + return; + } + + int xPos = this.bounds.x; + int yPos = this.bounds.y; + int accumPos = 0; + for(GlyphVector gv : this.lines){ + int glyphStart = accumPos; + int glyphLast = accumPos + gv.getNumGlyphs() - 1; + Rectangle2D r2d = gv.getLogicalBounds(); + Rectangle rect = r2d.getBounds(); + rect.x += xPos; + rect.y = yPos; + + if( fromIndex < 0 + && GUIUtils.getDirection(rect, fromPt) == SOUTH){ + yPos += rect.height; + accumPos = glyphLast + 1; + continue; + }else if( toIndex < 0 + && GUIUtils.getDirection(rect, toPt) == NORTH){ + break; + } + + if(fromIndex < 0){ + int dir = GUIUtils.getDirection(rect, fromPt); + if(dir == EAST){ + fromIndex = glyphStart; + }else if(dir == WEST){ + fromIndex = glyphLast+1; + } + } + if(toIndex < 0){ + int dir = GUIUtils.getDirection(rect, toPt); + if(dir == EAST){ + toIndex = glyphStart - 1; + }else if(dir == WEST){ + toIndex = glyphLast; + } + } + + if(fromIndex >= 0 && toIndex >= 0){ + select(fromIndex, toIndex); + return; + } + + yPos += rect.height; + accumPos = glyphLast + 1; + } + + clearSelect(); + return; + } + + /** + * 文字列検索がヒットした箇所のハイライト描画を行う。 + * @param g グラフィックスコンテキスト + */ + private void paintRegexHitted(Graphics2D g){ + if(this.matchList.size() <= 0) return; + + FontMetrics metrics = g.getFontMetrics(); + final int ascent = metrics.getAscent(); + + int xPos = this.bounds.x; + int yPos = this.bounds.y + ascent; + + int accumPos = 0; + + for(GlyphVector line : this.lines){ + int glyphStart = accumPos; + int glyphLast = accumPos + line.getNumGlyphs() - 1; + + for(MatchInfo match : this.matchList){ + int matchStart = match.getStartPos(); + int matchLast = match.getEndPos() - 1; + + if(matchLast < glyphStart) continue; + if(glyphLast < matchStart) break; + + int hilightStart = Math.max(matchStart, glyphStart); + int hilightLast = Math.min(matchLast, glyphLast); + Shape shape; + shape = line.getGlyphLogicalBounds(hilightStart - glyphStart); + Rectangle hilight = shape.getBounds(); + shape = line.getGlyphLogicalBounds(hilightLast - glyphStart); + hilight.add(shape.getBounds()); + + if(match == this.hotTarget){ + g.setColor(COLOR_HOTTARGET); + }else{ + g.setColor(COLOR_SEARCHHIT); + } + + g.fillRect(xPos + hilight.x, + yPos + hilight.y, + hilight.width, + hilight.height ); + } + + Rectangle2D r2d = line.getLogicalBounds(); + Rectangle rect = r2d.getBounds(); + + yPos += rect.height; + + accumPos = glyphLast + 1; + } + + return; + } + + /** + * 選択文字列のハイライト描画を行う。 + * @param g グラフィックスコンテキスト + */ + private void paintSelected(Graphics2D g){ + if(this.selectStart < 0 || this.selectLast < 0) return; + + g.setColor(COLOR_SELECTION); + + int xPos = this.bounds.x; + int yPos = this.bounds.y; + + int accumPos = 0; + + for(GlyphVector line : this.lines){ + int glyphStart = accumPos; + int glyphLast = accumPos + line.getNumGlyphs() - 1; + + if(this.selectLast < glyphStart) break; + + Rectangle2D r2d = line.getLogicalBounds(); + Rectangle rect = r2d.getBounds(); + + if(glyphLast < this.selectStart){ + yPos += rect.height; + accumPos = glyphLast + 1; + continue; + } + + int hilightStart = Math.max(this.selectStart, glyphStart); + int hilightLast = Math.min(this.selectLast, glyphLast); + Shape shape; + shape = line.getGlyphLogicalBounds(hilightStart - glyphStart); + Rectangle hilight = shape.getBounds(); + shape = line.getGlyphLogicalBounds(hilightLast - glyphStart); + hilight.add(shape.getBounds()); + + g.fillRect(xPos + hilight.x, + yPos, + hilight.width, + hilight.height ); + + yPos += rect.height; + accumPos = glyphLast + 1; + } + + return; + } + + /** + * アンカー文字列のハイライト描画を行う。 + * @param g グラフィックスコンテキスト + */ + private void paintAnchorBack(Graphics2D g){ + if(this.anchorSet == null) return; + if(this.anchorSet.size() <= 0) return; + + FontMetrics metrics = g.getFontMetrics(); + final int ascent = metrics.getAscent(); + + g.setColor(Color.GRAY); + + int xPos = this.bounds.x; + int yPos = this.bounds.y + ascent; + + int accumPos = 0; + + for(GlyphVector line : this.lines){ + int glyphStart = accumPos; + int glyphLast = accumPos + line.getNumGlyphs() - 1; + + for(Anchor anchor : this.anchorSet){ + int anchorStart = anchor.getStartPos(); + int anchorLast = anchor.getEndPos() - 1; + + if(anchorLast < glyphStart) continue; + if(glyphLast < anchorStart) break; + + int hilightStart = Math.max(anchorStart, glyphStart); + int hilightLast = Math.min(anchorLast, glyphLast); + Shape shape; + shape = line.getGlyphLogicalBounds(hilightStart - glyphStart); + Rectangle hilight = shape.getBounds(); + shape = line.getGlyphLogicalBounds(hilightLast - glyphStart); + hilight.add(shape.getBounds()); + + g.fillRect(xPos + hilight.x, + yPos + hilight.y, + hilight.width, + hilight.height ); + } + + Rectangle2D r2d = line.getLogicalBounds(); + Rectangle rect = r2d.getBounds(); + + yPos += rect.height; + + accumPos = glyphLast + 1; + } + + return; + } + + /** + * {@inheritDoc} + * @param g {@inheritDoc} + */ + public void paint(Graphics2D g){ + g.setFont(this.fontInfo.getFont()); + FontMetrics metrics = g.getFontMetrics(); + int ascent = metrics.getAscent(); + + int xPos = this.bounds.x; + int yPos = this.bounds.y + ascent; + + paintAnchorBack(g); + paintRegexHitted(g); + paintSelected(g); + + g.setColor(this.foregroundColor); + for(GlyphVector gv : this.lines){ + g.drawGlyphVector(gv, xPos, yPos); + + Rectangle2D r2d = gv.getLogicalBounds(); + Rectangle rect = r2d.getBounds(); + + yPos += rect.height; + } + + return; + } + + /** + * 与えられた座標にアンカー文字列が存在すればAnchorを返す。 + * @param pt 座標 + * @return アンカー + */ + public Anchor getAnchor(Point pt){ + int targetIdx = getCharIndex(pt); + if(targetIdx < 0) return null; + + for(Anchor anchor : this.anchorSet){ + int anchorStart = anchor.getStartPos(); + int anchorEnd = anchor.getEndPos(); + if(anchorStart <= targetIdx && targetIdx <= anchorEnd - 1){ + return anchor; + } + } + + return null; + } + + /** + * 与えられた座標に検索マッチ文字列があればそのインデックスを返す。 + * @param pt 座標 + * @return 検索マッチインデックス + */ + public int getRegexMatchIndex(Point pt){ + int targetIdx = getCharIndex(pt); + if(targetIdx < 0) return -1; + + int index = 0; + for(MatchInfo info : this.matchList){ + int matchStart = info.getStartPos(); + int matchEnd = info.getEndPos(); + if(matchStart <= targetIdx && targetIdx <= matchEnd - 1){ + return index; + } + index++; + } + + return -1; + } + + /** + * 検索文字列パターンを設定する。 + * @param searchRegex パターン + * @return ヒット数 + */ + public int setRegex(Pattern searchRegex){ + clearHotTarget(); + this.matchList.clear(); + if(searchRegex == null) return 0; + + Matcher matcher = searchRegex.matcher(this.source); + while(matcher.find()){ + int startPos = matcher.start(); + int endPos = matcher.end(); + if(startPos >= endPos) break; // 長さ0マッチは無視 + MatchInfo matchInfo = new MatchInfo(startPos, endPos); + this.matchList.add(matchInfo); + } + + return getRegexMatches(); + } + + /** + * 検索ハイライトインデックスを返す。 + * @return 検索ハイライトインデックス。見つからなければ-1。 + */ + public int getHotTargetIndex(){ + return this.matchList.indexOf(this.hotTarget); + } + + /** + * 検索ハイライトを設定する。 + * @param index ハイライトインデックス。負ならハイライト全クリア。 + */ + public void setHotTargetIndex(int index){ + if(index < 0){ + clearHotTarget(); + return; + } + this.hotTarget = this.matchList.get(index); + return; + } + + /** + * 検索一致件数を返す。 + * @return 検索一致件数 + */ + public int getRegexMatches(){ + return this.matchList.size(); + } + + /** + * 特別な検索ハイライト描画をクリアする。 + */ + public void clearHotTarget(){ + this.hotTarget = null; + return; + } + + /** + * 特別な検索ハイライト領域の寸法を返す。 + * @return ハイライト領域寸法 + */ + public Rectangle getHotTargetRectangle(){ + Rectangle result = null; + + if(this.hotTarget == null) return result; + + int xPos = this.bounds.x; + int yPos = this.bounds.y; + + int accumPos = 0; + + int matchStart = this.hotTarget.getStartPos(); + int matchLast = this.hotTarget.getEndPos() - 1; + + for(GlyphVector gv : this.lines){ + int glyphStart = accumPos; + int glyphLast = accumPos + gv.getNumGlyphs() - 1; + + if(matchLast < glyphStart) break; + + if(matchStart <= glyphLast){ + int hilightStart = Math.max(matchStart, glyphStart); + int hilightLast = Math.min(matchLast, glyphLast); + + Shape shape; + shape = gv.getGlyphLogicalBounds(hilightStart - glyphStart); + Rectangle hilight = shape.getBounds(); + shape = gv.getGlyphLogicalBounds(hilightLast - glyphStart); + hilight.add(shape.getBounds()); + + Rectangle temp = new Rectangle(xPos + hilight.x, + yPos, + hilight.width, + hilight.height); + if(result == null){ + result = temp; + }else{ + result.add(temp); + } + } + + Rectangle2D r2d = gv.getLogicalBounds(); + Rectangle rect = r2d.getBounds(); + yPos += rect.height; + + accumPos = glyphLast + 1; + } + + return result; + } + + /** + * 検索ヒット情報。 + */ + private static class MatchInfo{ + + private final int startPos; + private final int endPos; + + /** + * コンストラクタ。 + * @param startPos ヒット開始位置 + * @param endPos ヒット終了位置 + */ + public MatchInfo(int startPos, int endPos){ + super(); + this.startPos = startPos; + this.endPos = endPos; + return; + } + + /** + * ヒット開始位置を取得する。 + * @return ヒット開始位置 + */ + public int getStartPos(){ + return this.startPos; + } + + /** + * ヒット終了位置を取得する。 + * @return ヒット終了位置 + */ + public int getEndPos(){ + return this.endPos; + } + } + +}