diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ca65714 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/out/ +/*.jar \ No newline at end of file diff --git a/resources/META-INF/plugin.xml b/resources/META-INF/plugin.xml new file mode 100644 index 0000000..c96f5d3 --- /dev/null +++ b/resources/META-INF/plugin.xml @@ -0,0 +1,46 @@ + + com.darkyen.darkyenustimetracker + Darkyenus Time Tracker + 1.0 + Darkyen + Miscellaneous + + + Adds a single status bar widget: click to start counting, click again to stop. + Pauses the timer automatically when idle (after two minutes of inactivity). + Time is saved in IDE's workspace files, does not clutter project's directory. +
+ Source available on GitHub + ]]>
+ + Version 1.0 + + ]]> + + + + + + + com.intellij.modules.platform + + + + + + + + + + + + com.darkyen.TimeTrackerComponent + + + +
\ No newline at end of file diff --git a/src/com/darkyen/TimeTrackerComponent.java b/src/com/darkyen/TimeTrackerComponent.java new file mode 100644 index 0000000..3304d81 --- /dev/null +++ b/src/com/darkyen/TimeTrackerComponent.java @@ -0,0 +1,81 @@ +package com.darkyen; + +import com.intellij.openapi.components.*; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.wm.WindowManager; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * + */ +@State( + name="DarkyenusTimeTracker", + storages = {@Storage(value = StoragePathMacros.WORKSPACE_FILE, roamingType = RoamingType.DEFAULT)} + ) +public final class TimeTrackerComponent implements ProjectComponent, PersistentStateComponent { + + private final Project project; + private TimeTrackerWidget widget; + + private TimeTrackerState lastStateCache = null; + + public TimeTrackerComponent(Project project) { + this.project = project; + } + + @Override + public void initComponent() { + } + + @Override + public void disposeComponent() { + } + + @Override + @NotNull + public String getComponentName() { + return "TimeTrackerComponent"; + } + + @Override + public void projectOpened() { + if (widget == null) { + widget = new TimeTrackerWidget(); + if (lastStateCache != null) { + widget.setState(lastStateCache); + lastStateCache = null; + } + WindowManager.getInstance().getStatusBar(project).addWidget(widget); + } + } + + @Override + public void projectClosed() { + if (widget != null) { + WindowManager.getInstance().getStatusBar(project).removeWidget(widget.ID()); + lastStateCache = widget.getState(); + } + } + + @Nullable + @Override + public TimeTrackerState getState() { + if (widget != null) { + return widget.getState(); + } else if(lastStateCache != null) { + return lastStateCache; + } else { + return new TimeTrackerState(); + } + } + + @Override + public void loadState(TimeTrackerState state) { + if (widget != null) { + widget.setState(state); + } else { + lastStateCache = state; + } + } +} diff --git a/src/com/darkyen/TimeTrackerState.java b/src/com/darkyen/TimeTrackerState.java new file mode 100644 index 0000000..57fc74c --- /dev/null +++ b/src/com/darkyen/TimeTrackerState.java @@ -0,0 +1,10 @@ +package com.darkyen; + +/** + * + */ +@SuppressWarnings("WeakerAccess") +public final class TimeTrackerState { + public long totalTimeSeconds = 0; + public long idleThresholdMs = 2 * 60 * 1000; +} diff --git a/src/com/darkyen/TimeTrackerWidget.java b/src/com/darkyen/TimeTrackerWidget.java new file mode 100644 index 0000000..8d6412b --- /dev/null +++ b/src/com/darkyen/TimeTrackerWidget.java @@ -0,0 +1,237 @@ +package com.darkyen; + +import com.intellij.ide.ui.UISettings; +import com.intellij.openapi.wm.CustomStatusBarWidget; +import com.intellij.openapi.wm.StatusBar; +import com.intellij.openapi.wm.StatusBarWidget; +import com.intellij.ui.JBColor; +import com.intellij.util.concurrency.EdtExecutorService; +import com.intellij.util.ui.JBUI; +import com.intellij.util.ui.UIUtil; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import javax.swing.*; +import java.awt.*; +import java.awt.event.AWTEventListener; +import java.time.Duration; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +/** + * + */ +public final class TimeTrackerWidget extends JButton implements CustomStatusBarWidget, AWTEventListener { + + private TimeTrackerState state = new TimeTrackerState(); + + private boolean running = false; + private long startedAtMs = 0; + + private boolean idle = false; + private long lastActivityAtMs = System.currentTimeMillis(); + + private ScheduledFuture ticker; + + TimeTrackerWidget() { + addActionListener(e -> setRunning(!running)); + setBorder(StatusBarWidget.WidgetBorder.INSTANCE); + setOpaque(false); + setFocusable(false); + } + + void setState(TimeTrackerState state) { + this.state = state; + } + + synchronized TimeTrackerState getState() { + final long runningForSeconds = runningForSeconds(); + if (runningForSeconds > 0) { + state.totalTimeSeconds += runningForSeconds; + startedAtMs += runningForSeconds * 1000; + } + return state; + } + + private long runningForSeconds() { + if (!running) { + return 0; + } else { + return Math.max(System.currentTimeMillis() - startedAtMs, 0) / 1000; + } + } + + private synchronized void setRunning(boolean running) { + if (!this.running && running) { + this.running = true; + this.startedAtMs = System.currentTimeMillis(); + + if (ticker != null) { + ticker.cancel(false); + } + ticker = EdtExecutorService.getScheduledExecutorInstance().scheduleWithFixedDelay(() -> UIUtil.invokeLaterIfNeeded(() -> { + final long now = System.currentTimeMillis(); + if (now - lastActivityAtMs > state.idleThresholdMs) { + if (this.running) { + setRunning(false); + idle = true; + } + } + repaint(); + }), 1, 1, TimeUnit.SECONDS); + } else if(this.running && !running) { + state.totalTimeSeconds += runningForSeconds(); + this.running = false; + + if (ticker != null) { + ticker.cancel(false); + ticker = null; + } + } + } + + @NotNull + @Override + public String ID() { + return "com.darkyen.DarkyenusTimeTracker"; + } + + @Nullable + @Override + public WidgetPresentation getPresentation(@NotNull PlatformType type) { + return null; + } + + @Override + public void install(@NotNull StatusBar statusBar) { + Toolkit.getDefaultToolkit().addAWTEventListener(this, + AWTEvent.KEY_EVENT_MASK | + AWTEvent.MOUSE_EVENT_MASK | + AWTEvent.MOUSE_MOTION_EVENT_MASK + ); + } + + @Override + public void dispose() { + Toolkit.getDefaultToolkit().removeAWTEventListener(this); + setRunning(false); + } + + private static final Color COLOR_OFF = new JBColor(new Color(189, 0, 16), new Color(128, 0, 0)); + private static final Color COLOR_ON = new JBColor(new Color(28, 152, 19), new Color(56, 113, 41)); + private static final Color COLOR_IDLE = new JBColor(new Color(200, 164, 23), new Color(163, 112, 17)); + + @Override + public void paintComponent(final Graphics g) { + long result; + synchronized (this) { + result = state.totalTimeSeconds + runningForSeconds(); + } + final String info = formatDuration(result); + + final Dimension size = getSize(); + final Insets insets = getInsets(); + + final int totalBarLength = size.width - insets.left - insets.right; + final int barHeight = Math.max(size.height, getFont().getSize() + 2); + final int yOffset = (size.height - barHeight) / 2; + final int xOffset = insets.left; + + g.setColor(running ? COLOR_ON : (idle ? COLOR_IDLE : COLOR_OFF)); + g.fillRect(insets.left, insets.bottom, totalBarLength, size.height - insets.bottom - insets.top); + + final Color fg = getModel().isPressed() ? UIUtil.getLabelDisabledForeground() : JBColor.foreground(); + g.setColor(fg); + UISettings.setupAntialiasing(g); + g.setFont(getWidgetFont()); + final FontMetrics fontMetrics = g.getFontMetrics(); + final int infoWidth = fontMetrics.charsWidth(info.toCharArray(), 0, info.length()); + final int infoHeight = fontMetrics.getAscent(); + g.drawString(info, xOffset + (totalBarLength - infoWidth) / 2, yOffset + infoHeight + (barHeight - infoHeight) / 2 - 1); + } + + private static String formatDuration(long secondDuration) { + final Duration duration = Duration.ofSeconds(secondDuration); + final StringBuilder sb = new StringBuilder(); + + boolean found = false; + final long days = duration.toDays(); + if(days != 0) { + found = true; + sb.append(days).append(" day"); + if (days != 1) { + sb.append("s"); + } + } + final long hours = duration.toHours() % 24; + if(found || hours != 0) { + if(found) { + sb.append(" "); + } + found = true; + sb.append(hours).append(" hour"); + if (hours != 1) { + sb.append("s"); + } + } + final long minutes = duration.toMinutes() % 60; + if(found || minutes != 0) { + if(found) { + sb.append(" "); + } + found = true; + sb.append(minutes).append(" min");/* + if (minutes != 1) { + sb.append("s"); + }*/ + } + final long seconds = duration.getSeconds() % 60; + { + if(found) { + sb.append(" "); + } + sb.append(seconds).append(" sec");/* + if (seconds != 1) { + sb.append("s"); + }*/ + } + return sb.toString(); + } + + @Override + public JComponent getComponent() { + return this; + } + + private static Font getWidgetFont() { + return JBUI.Fonts.label(11); + } + + private static final String SAMPLE_STRING = formatDuration(999999999999L); + @Override + public Dimension getPreferredSize() { + final Insets insets = getInsets(); + int width = getFontMetrics(getWidgetFont()).stringWidth(SAMPLE_STRING) + insets.left + insets.right + JBUI.scale(2); + int height = getFontMetrics(getWidgetFont()).getHeight() + insets.top + insets.bottom + JBUI.scale(2); + return new Dimension(width, height); + } + + @Override + public Dimension getMinimumSize() { + return getPreferredSize(); + } + + @Override + public Dimension getMaximumSize() { + return getPreferredSize(); + } + + @Override + public void eventDispatched(AWTEvent event) { + lastActivityAtMs = System.currentTimeMillis(); + if (idle) { + idle = false; + setRunning(true); + } + } +}